This commit is contained in:
Jan Racek
2026-03-24 13:01:47 +01:00
parent 55c1d2c39b
commit e51c06b012
51 changed files with 8111 additions and 491 deletions

View File

@@ -9,6 +9,7 @@
#include <psyqo/kernel.hh>
#include <psyqo/matrix.hh>
#include <psyqo/primitives/common.hh>
#include <psyqo/primitives/control.hh>
#include <psyqo/primitives/triangles.hh>
#include <psyqo/soft-math.hh>
#include <psyqo/trigonometry.hh>
@@ -20,246 +21,739 @@ using namespace psyqo::fixed_point_literals;
using namespace psyqo::trig_literals;
using namespace psyqo::GTE;
psxsplash::Renderer *psxsplash::Renderer::instance = nullptr;
psxsplash::Renderer* psxsplash::Renderer::instance = nullptr;
void psxsplash::Renderer::Init(psyqo::GPU &gpuInstance) {
psyqo::Kernel::assert(instance == nullptr,
"A second intialization of Renderer was tried");
clear<Register::TRX, Safe>();
clear<Register::TRY, Safe>();
clear<Register::TRZ, Safe>();
write<Register::OFX, Safe>(psyqo::FixedPoint<16>(160.0).raw());
write<Register::OFY, Safe>(psyqo::FixedPoint<16>(120.0).raw());
write<Register::H, Safe>(120);
write<Register::ZSF3, Safe>(ORDERING_TABLE_SIZE / 3);
write<Register::ZSF4, Safe>(ORDERING_TABLE_SIZE / 4);
if (!instance) {
instance = new Renderer(gpuInstance);
}
void psxsplash::Renderer::Init(psyqo::GPU& gpuInstance) {
psyqo::Kernel::assert(instance == nullptr,
"A second initialization of Renderer was tried");
clear<Register::TRX, Safe>();
clear<Register::TRY, Safe>();
clear<Register::TRZ, Safe>();
write<Register::OFX, Safe>(psyqo::FixedPoint<16>(160.0).raw());
write<Register::OFY, Safe>(psyqo::FixedPoint<16>(120.0).raw());
write<Register::H, Safe>(PROJ_H);
write<Register::ZSF3, Safe>(ORDERING_TABLE_SIZE / 3);
write<Register::ZSF4, Safe>(ORDERING_TABLE_SIZE / 4);
if (!instance) { instance = new Renderer(gpuInstance); }
}
void psxsplash::Renderer::SetCamera(psxsplash::Camera &camera) {
m_currentCamera = &camera;
void psxsplash::Renderer::SetCamera(psxsplash::Camera& camera) { m_currentCamera = &camera; }
void psxsplash::Renderer::SetFog(const FogConfig& fog) {
m_fog = fog;
if (fog.enabled) {
m_clearcolor = fog.color;
write<Register::RFC, Unsafe>(static_cast<uint32_t>(fog.color.r) << 4);
write<Register::GFC, Unsafe>(static_cast<uint32_t>(fog.color.g) << 4);
write<Register::BFC, Safe>(static_cast<uint32_t>(fog.color.b) << 4);
m_fog.fogFarSZ = 8000 / fog.density;
} else {
m_clearcolor = {.r = 0, .g = 0, .b = 0};
m_fog.fogFarSZ = 0;
}
}
void psxsplash::Renderer::Render(eastl::vector<GameObject *> &objects) {
psyqo::Kernel::assert(m_currentCamera != nullptr,
"PSXSPLASH: Tried to render without an active camera");
uint8_t parity = m_gpu.getParity();
auto &ot = m_ots[parity];
auto &clear = m_clear[parity];
auto &balloc = m_ballocs[parity];
balloc.reset();
eastl::array<psyqo::Vertex, 3> projected;
for (auto &obj : objects) {
psyqo::Vec3 cameraPosition, objectPosition;
psyqo::Matrix33 finalMatrix;
void psxsplash::Renderer::writeFogRegisters() {
// Per-vertex fog is now computed manually in processTriangle (no DPCT).
// DQA/DQB/RFC/GFC/BFC are no longer needed for fog.
// The fog color is used directly via m_fog.color in the fogBlend function.
}
psyqo::Vec3 psxsplash::Renderer::computeCameraViewPos() {
::clear<Register::TRX, Safe>();
::clear<Register::TRY, Safe>();
::clear<Register::TRZ, Safe>();
// Rotate the camera Translation vector by the camera rotation
writeSafe<PseudoRegister::Rotation>(m_currentCamera->GetRotation());
writeSafe<PseudoRegister::V0>(-m_currentCamera->GetPosition());
Kernels::mvmva<Kernels::MX::RT, Kernels::MV::V0, Kernels::TV::TR>();
cameraPosition = readSafe<PseudoRegister::SV>();
return readSafe<PseudoRegister::SV>();
}
// Rotate the object Translation vector by the camera rotation
void psxsplash::Renderer::setupObjectTransform(
GameObject* obj, const psyqo::Vec3& cameraPosition) {
::clear<Register::TRX, Safe>();
::clear<Register::TRY, Safe>();
::clear<Register::TRZ, Safe>();
writeSafe<PseudoRegister::Rotation>(m_currentCamera->GetRotation());
writeSafe<PseudoRegister::V0>(obj->position);
Kernels::mvmva<Kernels::MX::RT, Kernels::MV::V0, Kernels::TV::TR>();
objectPosition = readSafe<PseudoRegister::SV>();
psyqo::Vec3 objectPosition = readSafe<PseudoRegister::SV>();
objectPosition.x += cameraPosition.x;
objectPosition.y += cameraPosition.y;
objectPosition.z += cameraPosition.z;
// Combine object and camera rotations
MatrixMultiplyGTE(m_currentCamera->GetRotation(), obj->rotation,
&finalMatrix);
psyqo::GTE::writeSafe<psyqo::GTE::PseudoRegister::Translation>(
objectPosition);
psyqo::GTE::writeSafe<psyqo::GTE::PseudoRegister::Rotation>(finalMatrix);
for (int i = 0; i < obj->polyCount; i++) {
Tri &tri = obj->polygons[i];
psyqo::Vec3 result;
writeSafe<PseudoRegister::V0>(tri.v0);
writeSafe<PseudoRegister::V1>(tri.v1);
writeSafe<PseudoRegister::V2>(tri.v2);
Kernels::rtpt();
Kernels::nclip();
/*int32_t mac0 = 0;
read<Register::MAC0>(reinterpret_cast<uint32_t *>(&mac0));
if (mac0 <= 0)
continue;*/
int32_t zIndex = 0;
uint32_t u0, u1, u2;
read<Register::SZ1>(&u0);
read<Register::SZ2>(&u1);
read<Register::SZ3>(&u2);
int32_t sz0 = (int32_t)u0;
int32_t sz1 = (int32_t)u1;
int32_t sz2 = (int32_t)u2;
if ((sz0 < 1 && sz1 < 1 && sz2 < 1)) {
continue;
};
zIndex = eastl::max(eastl::max(sz0, sz1), sz2);
if (zIndex < 0 || zIndex >= ORDERING_TABLE_SIZE)
continue;
read<Register::SXY0>(&projected[0].packed);
read<Register::SXY1>(&projected[1].packed);
read<Register::SXY2>(&projected[2].packed);
auto &prim =
balloc.allocateFragment<psyqo::Prim::GouraudTexturedTriangle>();
prim.primitive.pointA = projected[0];
prim.primitive.pointB = projected[1];
prim.primitive.pointC = projected[2];
prim.primitive.uvA = tri.uvA;
prim.primitive.uvB = tri.uvB;
prim.primitive.uvC = tri.uvC;
prim.primitive.tpage = tri.tpage;
psyqo::PrimPieces::ClutIndex clut(tri.clutX, tri.clutY);
prim.primitive.clutIndex = clut;
prim.primitive.setColorA(tri.colorA);
prim.primitive.setColorB(tri.colorB);
prim.primitive.setColorC(tri.colorC);
prim.primitive.setOpaque();
m_ots[m_gpu.getParity()].insert(prim, zIndex);
}
}
m_gpu.getNextClear(clear.primitive, m_clearcolor);
m_gpu.chain(clear);
m_gpu.chain(ot);
psyqo::Matrix33 finalMatrix;
MatrixMultiplyGTE(m_currentCamera->GetRotation(), obj->rotation, &finalMatrix);
writeSafe<PseudoRegister::Translation>(objectPosition);
writeSafe<PseudoRegister::Rotation>(finalMatrix);
}
void psxsplash::Renderer::RenderNavmeshPreview(psxsplash::Navmesh navmesh,
bool isOnMesh) {
uint8_t parity = m_gpu.getParity();
eastl::array<psyqo::Vertex, 3> projected;
// Per-vertex fog blend: result = vertexColor * (4096 - ir0) / 4096 + fogColor * ir0 / 4096
static inline psyqo::Color fogBlend(psyqo::Color vc, int32_t ir0, psyqo::Color fogC) {
if (ir0 <= 0) return vc;
if (ir0 >= 4096) return fogC;
int32_t keep = 4096 - ir0;
return {
.r = (uint8_t)((vc.r * keep + fogC.r * ir0) >> 12),
.g = (uint8_t)((vc.g * keep + fogC.g * ir0) >> 12),
.b = (uint8_t)((vc.b * keep + fogC.b * ir0) >> 12),
};
}
auto &ot = m_ots[parity];
auto &clear = m_clear[parity];
auto &balloc = m_ballocs[parity];
balloc.reset();
// ============================================================================
// Core triangle pipeline (Bandwidth's proven approach + fog)
// rtpt -> nclip -> backface cull -> SZ depth -> SXY -> screen clip -> emit
// ============================================================================
psyqo::Vec3 cameraPosition;
::clear<Register::TRX, Safe>();
::clear<Register::TRY, Safe>();
::clear<Register::TRZ, Safe>();
// Rotate the camera Translation vector by the camera rotation
writeSafe<PseudoRegister::Rotation>(m_currentCamera->GetRotation());
writeSafe<PseudoRegister::V0>(m_currentCamera->GetPosition());
Kernels::mvmva<Kernels::MX::RT, Kernels::MV::V0, Kernels::TV::TR>();
cameraPosition = readSafe<PseudoRegister::SV>();
write<Register::TRX, Safe>(-cameraPosition.x.raw());
write<Register::TRY, Safe>(-cameraPosition.y.raw());
write<Register::TRZ, Safe>(-cameraPosition.z.raw());
psyqo::GTE::writeSafe<psyqo::GTE::PseudoRegister::Rotation>(
m_currentCamera->GetRotation());
for (int i = 0; i < navmesh.triangleCount; i++) {
NavMeshTri &tri = navmesh.polygons[i];
psyqo::Vec3 result;
void psxsplash::Renderer::processTriangle(
Tri& tri, int32_t fogFarSZ,
psyqo::OrderingTable<ORDERING_TABLE_SIZE>& ot,
psyqo::BumpAllocator<BUMP_ALLOCATOR_SIZE>& balloc) {
writeSafe<PseudoRegister::V0>(tri.v0);
writeSafe<PseudoRegister::V1>(tri.v1);
writeSafe<PseudoRegister::V2>(tri.v2);
Kernels::rtpt();
Kernels::nclip();
int32_t mac0 = 0;
read<Register::MAC0>(reinterpret_cast<uint32_t *>(&mac0));
if (mac0 <= 0)
continue;
int32_t zIndex = 0;
uint32_t u0, u1, u2;
read<Register::SZ0>(&u0);
read<Register::SZ1>(&u1);
read<Register::SZ2>(&u2);
read<Register::SZ1>(&u0);
read<Register::SZ2>(&u1);
read<Register::SZ3>(&u2);
int32_t sz0 = (int32_t)u0, sz1 = (int32_t)u1, sz2 = (int32_t)u2;
int32_t sz0 = *reinterpret_cast<int32_t *>(&u0);
int32_t sz1 = *reinterpret_cast<int32_t *>(&u1);
int32_t sz2 = *reinterpret_cast<int32_t *>(&u2);
if (sz0 < 1 && sz1 < 1 && sz2 < 1) return;
if (fogFarSZ > 0 && sz0 > fogFarSZ && sz1 > fogFarSZ && sz2 > fogFarSZ) return;
zIndex = eastl::max(eastl::max(sz0, sz1), sz2);
if (zIndex < 0 || zIndex >= ORDERING_TABLE_SIZE)
continue;
int32_t zIndex = eastl::max(eastl::max(sz0, sz1), sz2);
if (zIndex < 0 || zIndex >= (int32_t)ORDERING_TABLE_SIZE) return;
// Per-vertex fog: compute fog factor for each vertex individually based on
// its SZ depth. The GPU then interpolates the fogged colors smoothly across
// the triangle surface, eliminating the per-triangle tiling artifacts that
// occur when a single IR0 is used for the whole triangle.
//
// fogIR[i] = 0 means no fog (original color), 4096 = full fog (fog color).
// Quadratic ease-in curve: fog dominates over baked lighting quickly.
int32_t fogIR[3] = {0, 0, 0};
if (fogFarSZ > 0) {
int32_t fogNear = fogFarSZ / 4;
int32_t range4 = (fogFarSZ - fogNear) >> 4;
if (range4 < 1) range4 = 1;
int32_t scale = 4096 / range4;
int32_t szArr[3] = {sz0, sz1, sz2};
for (int vi = 0; vi < 3; vi++) {
int32_t ir;
if (szArr[vi] <= fogNear) {
ir = 0;
} else if (szArr[vi] >= fogFarSZ) {
ir = 4096;
} else {
ir = ((szArr[vi] - fogNear) * scale) >> 4;
if (ir > 4096) ir = 4096;
int32_t inv = 4096 - ir;
ir = 4096 - ((inv >> 2) * (inv >> 2) >> 8);
if (ir < 0) ir = 0;
}
fogIR[vi] = ir;
}
}
psyqo::Vertex projected[3];
read<Register::SXY0>(&projected[0].packed);
read<Register::SXY1>(&projected[1].packed);
read<Register::SXY2>(&projected[2].packed);
auto &prim = balloc.allocateFragment<psyqo::Prim::Triangle>();
if (isCompletelyOutside(projected[0], projected[1], projected[2])) return;
prim.primitive.pointA = projected[0];
prim.primitive.pointB = projected[1];
prim.primitive.pointC = projected[2];
psyqo::Color heightColor;
if (isOnMesh) {
heightColor.r = 0;
heightColor.g =
((tri.v0.y.raw() + tri.v1.y.raw() + tri.v2.y.raw()) / 3) * 100 % 256;
heightColor.b = 0;
} else {
heightColor.r =
((tri.v0.y.raw() + tri.v1.y.raw() + tri.v2.y.raw()) / 3) * 100 % 256;
heightColor.g = 0;
heightColor.b = 0;
// Triangles that need clipping skip nclip entirely.
// nclip with GTE-clamped screen coords gives wrong results for edge triangles.
// The clipper handles them directly - no backface cull needed since the
// clipper preserves winding and degenerate triangles produce zero-area output.
if (needsClipping(projected[0], projected[1], projected[2])) {
ClipVertex cv0 = {(int16_t)projected[0].x, (int16_t)projected[0].y, (int16_t)sz0,
tri.uvA.u, tri.uvA.v, tri.colorA.r, tri.colorA.g, tri.colorA.b};
ClipVertex cv1 = {(int16_t)projected[1].x, (int16_t)projected[1].y, (int16_t)sz1,
tri.uvB.u, tri.uvB.v, tri.colorB.r, tri.colorB.g, tri.colorB.b};
ClipVertex cv2 = {(int16_t)projected[2].x, (int16_t)projected[2].y, (int16_t)sz2,
tri.uvC.u, tri.uvC.v, tri.colorC.r, tri.colorC.g, tri.colorC.b};
ClipResult clipResult;
int clippedCount = clipTriangle(cv0, cv1, cv2, clipResult);
for (int ct = 0; ct < clippedCount; ct++) {
const ClipVertex& a = clipResult.verts[ct*3];
const ClipVertex& b = clipResult.verts[ct*3+1];
const ClipVertex& c = clipResult.verts[ct*3+2];
// For clipped vertices, use per-triangle fog (max SZ) since
// clipped vertex Z values may not map cleanly to the original SZs.
psyqo::Color ca = {a.r, a.g, a.b}, cb = {b.r, b.g, b.b}, cc = {c.r, c.g, c.b};
if (m_fog.enabled) {
int32_t maxIR = eastl::max(eastl::max(fogIR[0], fogIR[1]), fogIR[2]);
ca = fogBlend(ca, maxIR, m_fog.color);
cb = fogBlend(cb, maxIR, m_fog.color);
cc = fogBlend(cc, maxIR, m_fog.color);
}
if (tri.isUntextured()) {
auto& p = balloc.allocateFragment<psyqo::Prim::GouraudTriangle>();
p.primitive.pointA.x = a.x; p.primitive.pointA.y = a.y;
p.primitive.pointB.x = b.x; p.primitive.pointB.y = b.y;
p.primitive.pointC.x = c.x; p.primitive.pointC.y = c.y;
p.primitive.setColorA(ca); p.primitive.setColorB(cb); p.primitive.setColorC(cc);
p.primitive.setOpaque();
ot.insert(p, zIndex);
} else {
auto& p = balloc.allocateFragment<psyqo::Prim::GouraudTexturedTriangle>();
p.primitive.pointA.x = a.x; p.primitive.pointA.y = a.y;
p.primitive.pointB.x = b.x; p.primitive.pointB.y = b.y;
p.primitive.pointC.x = c.x; p.primitive.pointC.y = c.y;
p.primitive.uvA.u = a.u; p.primitive.uvA.v = a.v;
p.primitive.uvB.u = b.u; p.primitive.uvB.v = b.v;
p.primitive.uvC.u = c.u; p.primitive.uvC.v = c.v;
p.primitive.tpage = tri.tpage;
psyqo::PrimPieces::ClutIndex clut(tri.clutX, tri.clutY);
p.primitive.clutIndex = clut;
p.primitive.setColorA(ca); p.primitive.setColorB(cb); p.primitive.setColorC(cc);
p.primitive.setOpaque();
ot.insert(p, zIndex);
}
}
return;
}
prim.primitive.setColor(heightColor);
prim.primitive.setOpaque();
ot.insert(prim, zIndex);
}
m_gpu.getNextClear(clear.primitive, m_clearcolor);
m_gpu.chain(clear);
m_gpu.chain(ot);
// Normal path: triangle is fully inside clip region with safe deltas.
// nclip is reliable here since screen coords aren't clamped.
Kernels::nclip();
int32_t mac0 = 0;
read<Register::MAC0>(reinterpret_cast<uint32_t*>(&mac0));
if (mac0 <= 0) return;
// Per-vertex fog: blend each vertex color toward fog color based on its depth.
// GPU interpolates these smoothly across the triangle - no tiling artifacts.
psyqo::Color cA = tri.colorA, cB = tri.colorB, cC = tri.colorC;
if (m_fog.enabled) {
cA = fogBlend(cA, fogIR[0], m_fog.color);
cB = fogBlend(cB, fogIR[1], m_fog.color);
cC = fogBlend(cC, fogIR[2], m_fog.color);
}
if (tri.isUntextured()) {
auto& p = balloc.allocateFragment<psyqo::Prim::GouraudTriangle>();
p.primitive.pointA = projected[0]; p.primitive.pointB = projected[1]; p.primitive.pointC = projected[2];
p.primitive.setColorA(cA); p.primitive.setColorB(cB); p.primitive.setColorC(cC);
p.primitive.setOpaque();
ot.insert(p, zIndex);
} else {
auto& p = balloc.allocateFragment<psyqo::Prim::GouraudTexturedTriangle>();
p.primitive.pointA = projected[0]; p.primitive.pointB = projected[1]; p.primitive.pointC = projected[2];
p.primitive.uvA = tri.uvA; p.primitive.uvB = tri.uvB; p.primitive.uvC = tri.uvC;
p.primitive.tpage = tri.tpage;
psyqo::PrimPieces::ClutIndex clut(tri.clutX, tri.clutY);
p.primitive.clutIndex = clut;
p.primitive.setColorA(cA); p.primitive.setColorB(cB); p.primitive.setColorC(cC);
p.primitive.setOpaque();
ot.insert(p, zIndex);
}
}
void psxsplash::Renderer::VramUpload(const uint16_t *imageData, int16_t posX,
int16_t posY, int16_t width,
int16_t height) {
psyqo::Rect uploadRect{.a = {.x = posX, .y = posY}, .b = {width, height}};
m_gpu.uploadToVRAM(imageData, uploadRect);
// ============================================================================
// Render paths
// ============================================================================
void psxsplash::Renderer::Render(eastl::vector<GameObject*>& objects) {
psyqo::Kernel::assert(m_currentCamera != nullptr, "PSXSPLASH: Tried to render without an active camera");
uint8_t parity = m_gpu.getParity();
auto& ot = m_ots[parity]; auto& clear = m_clear[parity]; auto& balloc = m_ballocs[parity];
balloc.reset();
// Set dithering draw mode at the back of the OT so it fires before any geometry.
auto& ditherCmd = balloc.allocateFragment<psyqo::Prim::TPage>();
ditherCmd.primitive.attr.setDithering(true);
ot.insert(ditherCmd, ORDERING_TABLE_SIZE - 1);
writeFogRegisters();
psyqo::Vec3 cameraPosition = computeCameraViewPos();
int32_t fogFarSZ = m_fog.fogFarSZ;
for (auto& obj : objects) {
setupObjectTransform(obj, cameraPosition);
for (int i = 0; i < obj->polyCount; i++)
processTriangle(obj->polygons[i], fogFarSZ, ot, balloc);
}
m_gpu.getNextClear(clear.primitive, m_clearcolor);
m_gpu.chain(clear); m_gpu.chain(ot);
m_frameCount++;
}
psyqo::Color averageColor(const psyqo::Color &a, const psyqo::Color &b) {
return psyqo::Color{static_cast<uint8_t>((a.r + b.r) >> 1),
static_cast<uint8_t>((a.g + b.g) >> 1),
static_cast<uint8_t>((a.b + b.b) >> 1)};
void psxsplash::Renderer::RenderWithBVH(eastl::vector<GameObject*>& objects, const BVHManager& bvh) {
psyqo::Kernel::assert(m_currentCamera != nullptr, "PSXSPLASH: Tried to render without an active camera");
if (!bvh.isLoaded()) { Render(objects); return; }
uint8_t parity = m_gpu.getParity();
auto& ot = m_ots[parity]; auto& clear = m_clear[parity]; auto& balloc = m_ballocs[parity];
balloc.reset();
auto& ditherCmd2 = balloc.allocateFragment<psyqo::Prim::TPage>();
ditherCmd2.primitive.attr.setDithering(true);
ot.insert(ditherCmd2, ORDERING_TABLE_SIZE - 1);
writeFogRegisters();
Frustum frustum; m_currentCamera->ExtractFrustum(frustum);
int visibleCount = bvh.cullFrustum(frustum, m_visibleRefs, MAX_VISIBLE_TRIANGLES);
psyqo::Vec3 cameraPosition = computeCameraViewPos();
int32_t fogFarSZ = m_fog.fogFarSZ;
int16_t lastObjectIndex = -1;
for (int i = 0; i < visibleCount; i++) {
const TriangleRef& ref = m_visibleRefs[i];
if (ref.objectIndex >= objects.size()) continue;
GameObject* obj = objects[ref.objectIndex];
if (ref.triangleIndex >= obj->polyCount) continue;
if (ref.objectIndex != lastObjectIndex) {
lastObjectIndex = ref.objectIndex;
setupObjectTransform(obj, cameraPosition);
}
processTriangle(obj->polygons[ref.triangleIndex], fogFarSZ, ot, balloc);
}
m_gpu.getNextClear(clear.primitive, m_clearcolor);
m_gpu.chain(clear); m_gpu.chain(ot);
m_frameCount++;
}
// ============================================================================
// RenderWithRooms - Portal/room occlusion for interior scenes
// ============================================================================
struct ScreenRect { int16_t minX, minY, maxX, maxY; };
static inline bool intersectRect(const ScreenRect& a, const ScreenRect& b, ScreenRect& out) {
out.minX = (a.minX > b.minX) ? a.minX : b.minX; out.minY = (a.minY > b.minY) ? a.minY : b.minY;
out.maxX = (a.maxX < b.maxX) ? a.maxX : b.maxX; out.maxY = (a.maxY < b.maxY) ? a.maxY : b.maxY;
return out.minX < out.maxX && out.minY < out.maxY;
}
// Safety margin added to portal screen rects (pixels).
// Prevents geometry from popping at portal edges due to fixed-point rounding.
static constexpr int16_t PORTAL_MARGIN = 16;
// Transform a world-space point to camera space using the view rotation matrix.
static inline void worldToCamera(int32_t wx, int32_t wy, int32_t wz,
int32_t camX, int32_t camY, int32_t camZ,
const psyqo::Matrix33& camRot,
int32_t& outX, int32_t& outY, int32_t& outZ) {
int32_t rx = wx - camX, ry = wy - camY, rz = wz - camZ;
outX = (int32_t)(((int64_t)camRot.vs[0].x.value * rx + (int64_t)camRot.vs[0].y.value * ry +
(int64_t)camRot.vs[0].z.value * rz) >> 12);
outY = (int32_t)(((int64_t)camRot.vs[1].x.value * rx + (int64_t)camRot.vs[1].y.value * ry +
(int64_t)camRot.vs[1].z.value * rz) >> 12);
outZ = (int32_t)(((int64_t)camRot.vs[2].x.value * rx + (int64_t)camRot.vs[2].y.value * ry +
(int64_t)camRot.vs[2].z.value * rz) >> 12);
}
// Project a camera-space point to screen coordinates.
// Returns false if behind near plane.
static inline bool projectToScreen(int32_t vx, int32_t vy, int32_t vz,
int16_t& sx, int16_t& sy) {
if (vz <= 0) return false;
constexpr int32_t H = 120;
int32_t vzs = vz >> 4; if (vzs <= 0) vzs = 1;
sx = (int16_t)((vx >> 4) * H / vzs + 160);
sy = (int16_t)((vy >> 4) * H / vzs + 120);
return true;
}
// Project a portal quad to a screen-space AABB.
// Computes the 4 corners, transforms to camera space, clips against the near plane,
// projects visible points to screen, and returns the bounding rect.
static bool projectPortalRect(const psxsplash::PortalData& portal,
int32_t camX, int32_t camY, int32_t camZ, const psyqo::Matrix33& camRot, ScreenRect& outRect) {
// Compute portal corner offsets in world space.
int32_t rwx = ((int32_t)portal.rightX * portal.halfW) >> 12;
int32_t rwy = ((int32_t)portal.rightY * portal.halfW) >> 12;
int32_t rwz = ((int32_t)portal.rightZ * portal.halfW) >> 12;
int32_t uhx = ((int32_t)portal.upX * portal.halfH) >> 12;
int32_t uhy = ((int32_t)portal.upY * portal.halfH) >> 12;
int32_t uhz = ((int32_t)portal.upZ * portal.halfH) >> 12;
int32_t cx = portal.centerX, cy = portal.centerY, cz = portal.centerZ;
// Transform 4 corners to camera space
struct CamVert { int32_t x, y, z; };
CamVert cv[4];
int32_t wCorners[4][3] = {
{cx + rwx + uhx, cy + rwy + uhy, cz + rwz + uhz},
{cx - rwx + uhx, cy - rwy + uhy, cz - rwz + uhz},
{cx - rwx - uhx, cy - rwy - uhy, cz - rwz - uhz},
{cx + rwx - uhx, cy + rwy - uhy, cz + rwz - uhz},
};
int behindCount = 0;
for (int i = 0; i < 4; i++) {
worldToCamera(wCorners[i][0], wCorners[i][1], wCorners[i][2],
camX, camY, camZ, camRot, cv[i].x, cv[i].y, cv[i].z);
if (cv[i].z <= 0) behindCount++;
}
if (behindCount == 4) {
// All corners behind camera. Only allow if camera is very close to portal.
int32_t vx, vy, vz;
worldToCamera(cx, cy, cz, camX, camY, camZ, camRot, vx, vy, vz);
int32_t portalExtent = portal.halfW > portal.halfH ? portal.halfW : portal.halfH;
if (-vz > portalExtent * 2) return false;
outRect = {-512, -512, 832, 752};
return true;
}
// Clip against near plane (z=1) and project visible points.
// For each edge where one vertex is in front and one behind,
// compute the intersection point and include it in the screen rect.
constexpr int32_t NEAR_Z = 1;
int16_t sxMin = 32767, sxMax = -32768;
int16_t syMin = 32767, syMax = -32768;
int projCount = 0;
for (int i = 0; i < 4; i++) {
int j = (i + 1) % 4;
// Project vertex i if in front
if (cv[i].z > 0) {
int16_t sx, sy;
if (projectToScreen(cv[i].x, cv[i].y, cv[i].z, sx, sy)) {
if (sx < sxMin) sxMin = sx;
if (sx > sxMax) sxMax = sx;
if (sy < syMin) syMin = sy;
if (sy > syMax) syMax = sy;
projCount++;
}
}
// If edge crosses the near plane, clip and project the intersection.
// All 32-bit arithmetic (no __divdi3 on MIPS R3000).
bool iFront = cv[i].z > 0;
bool jFront = cv[j].z > 0;
if (iFront != jFront) {
int32_t dz = cv[j].z - cv[i].z;
if (dz == 0) continue;
int32_t dzs = dz >> 4;
if (dzs == 0) dzs = (dz > 0) ? 1 : -1; // prevent div-by-zero after shift
// Compute t in 4.12 fixed-point. Shift num/den by 4 to keep * 4096 in 32 bits.
int32_t t12 = (((NEAR_Z - cv[i].z) >> 4) * 4096) / dzs;
// Apply t: clip = cv[i] + (cv[j] - cv[i]) * t12 / 4096
// Shift dx by 4 so (dx>>4)*t12 fits int32, then >>8 to undo (4+8=12 total)
int32_t clipX = cv[i].x + ((((cv[j].x - cv[i].x) >> 4) * t12) >> 8);
int32_t clipY = cv[i].y + ((((cv[j].y - cv[i].y) >> 4) * t12) >> 8);
int16_t sx, sy;
if (projectToScreen(clipX, clipY, NEAR_Z, sx, sy)) {
if (sx < sxMin) sxMin = sx;
if (sx > sxMax) sxMax = sx;
if (sy < syMin) syMin = sy;
if (sy > syMax) syMax = sy;
projCount++;
}
}
}
if (projCount == 0) return false;
outRect = {
(int16_t)(sxMin - PORTAL_MARGIN), (int16_t)(syMin - PORTAL_MARGIN),
(int16_t)(sxMax + PORTAL_MARGIN), (int16_t)(syMax + PORTAL_MARGIN)
};
return true;
}
// Test if a room's AABB is potentially visible to the camera frustum.
// Quick rejection test: if the room is entirely behind the camera, skip it.
static bool isRoomPotentiallyVisible(const psxsplash::RoomData& room,
int32_t camX, int32_t camY, int32_t camZ, const psyqo::Matrix33& camRot) {
// Transform the room's AABB center to camera space and check Z.
// Use the p-vertex approach: find the corner most in the camera forward direction.
int32_t fwdX = camRot.vs[2].x.value;
int32_t fwdY = camRot.vs[2].y.value;
int32_t fwdZ = camRot.vs[2].z.value;
// p-vertex: corner of AABB closest to camera forward direction
int32_t px = (fwdX >= 0) ? room.aabbMaxX : room.aabbMinX;
int32_t py = (fwdY >= 0) ? room.aabbMaxY : room.aabbMinY;
int32_t pz = (fwdZ >= 0) ? room.aabbMaxZ : room.aabbMinZ;
// If p-vertex is behind camera, the entire AABB is behind
int32_t rx = px - camX, ry = py - camY, rz = pz - camZ;
int64_t dotFwd = ((int64_t)fwdX * rx + (int64_t)fwdY * ry + (int64_t)fwdZ * rz) >> 12;
if (dotFwd < -4096) return false; // Entirely behind with 1-unit margin
return true;
}
void psxsplash::Renderer::RenderWithRooms(eastl::vector<GameObject*>& objects,
const RoomData* rooms, int roomCount, const PortalData* portals, int portalCount,
const TriangleRef* roomTriRefs, int cameraRoom) {
psyqo::Kernel::assert(m_currentCamera != nullptr, "PSXSPLASH: Tried to render without an active camera");
if (roomCount == 0 || rooms == nullptr) { Render(objects); return; }
uint8_t parity = m_gpu.getParity();
auto& ot = m_ots[parity]; auto& clear = m_clear[parity]; auto& balloc = m_ballocs[parity];
balloc.reset();
auto& ditherCmd3 = balloc.allocateFragment<psyqo::Prim::TPage>();
ditherCmd3.primitive.attr.setDithering(true);
ot.insert(ditherCmd3, ORDERING_TABLE_SIZE - 1);
writeFogRegisters();
psyqo::Vec3 cameraPosition = computeCameraViewPos();
int32_t fogFarSZ = m_fog.fogFarSZ;
int32_t camX = m_currentCamera->GetPosition().x.raw();
int32_t camY = m_currentCamera->GetPosition().y.raw();
int32_t camZ = m_currentCamera->GetPosition().z.raw();
int catchAllIdx = roomCount - 1;
// If no camera room provided (or invalid), fall back to AABB containment.
// Pick the smallest room whose AABB (with margin) contains the camera.
if (cameraRoom < 0 || cameraRoom >= catchAllIdx) {
constexpr int32_t ROOM_MARGIN = 2048; // 0.5 units in fp12
int64_t bestVolume = 0x7FFFFFFFFFFFFFFFLL;
for (int r = 0; r < catchAllIdx; r++) {
if (camX >= rooms[r].aabbMinX - ROOM_MARGIN && camX <= rooms[r].aabbMaxX + ROOM_MARGIN &&
camY >= rooms[r].aabbMinY - ROOM_MARGIN && camY <= rooms[r].aabbMaxY + ROOM_MARGIN &&
camZ >= rooms[r].aabbMinZ - ROOM_MARGIN && camZ <= rooms[r].aabbMaxZ + ROOM_MARGIN) {
int64_t dx = (int64_t)(rooms[r].aabbMaxX - rooms[r].aabbMinX);
int64_t dy = (int64_t)(rooms[r].aabbMaxY - rooms[r].aabbMinY);
int64_t dz = (int64_t)(rooms[r].aabbMaxZ - rooms[r].aabbMinZ);
int64_t vol = dx * dy + dy * dz + dx * dz;
if (vol < bestVolume) { bestVolume = vol; cameraRoom = r; }
}
}
}
uint32_t visited = 0;
if (catchAllIdx < 32) visited = (1u << catchAllIdx);
const auto& camRot = m_currentCamera->GetRotation();
struct Entry { int room; int depth; ScreenRect clip; };
Entry stack[64]; int top = 0;
auto renderRoom = [&](int ri) {
const RoomData& rm = rooms[ri];
int16_t lastObj = -1;
for (int ti = 0; ti < rm.triRefCount; ti++) {
const TriangleRef& ref = roomTriRefs[rm.firstTriRef + ti];
if (ref.objectIndex >= objects.size()) continue;
GameObject* obj = objects[ref.objectIndex];
if (ref.triangleIndex >= obj->polyCount) continue;
if (ref.objectIndex != lastObj) { lastObj = ref.objectIndex; setupObjectTransform(obj, cameraPosition); }
processTriangle(obj->polygons[ref.triangleIndex], fogFarSZ, ot, balloc);
}
};
// Always render catch-all room (geometry not assigned to any specific room)
renderRoom(catchAllIdx);
if (cameraRoom >= 0) {
ScreenRect full = {-512, -512, 832, 752};
if (cameraRoom < 32) visited |= (1u << cameraRoom);
stack[top++] = {cameraRoom, 0, full};
while (top > 0) {
Entry e = stack[--top];
renderRoom(e.room);
if (e.depth >= 8) continue; // Depth limit prevents infinite loops
for (int p = 0; p < portalCount; p++) {
int other = -1;
if (portals[p].roomA == e.room) other = portals[p].roomB;
else if (portals[p].roomB == e.room) other = portals[p].roomA;
else continue;
if (other < 0 || other >= roomCount) continue;
if (other < 32 && (visited & (1u << other))) continue;
// Backface cull: skip portals that face away from the camera.
// The portal normal points from roomA toward roomB (4.12 fp).
// dot(normal, cam - portalCenter) > 0 means the portal faces us when
// traversing A->B; the sign flips when traversing B->A.
{
int32_t dx = camX - portals[p].centerX;
int32_t dy = camY - portals[p].centerY;
int32_t dz = camZ - portals[p].centerZ;
int64_t dot = (int64_t)dx * portals[p].normalX +
(int64_t)dy * portals[p].normalY +
(int64_t)dz * portals[p].normalZ;
// Allow a small negative threshold so nearly-edge-on portals still pass.
const int64_t BACKFACE_THRESHOLD = -4096;
if (portals[p].roomA == e.room) {
if (dot < BACKFACE_THRESHOLD) continue;
} else {
if (dot > -BACKFACE_THRESHOLD) continue;
}
}
// Phase 4: Frustum-cull the destination room's AABB.
// If the room is entirely behind the camera, skip.
if (!isRoomPotentiallyVisible(rooms[other], camX, camY, camZ, camRot)) {
continue;
}
// Phase 2: Project actual portal quad corners to screen.
ScreenRect pr;
if (!projectPortalRect(portals[p], camX, camY, camZ, camRot, pr)) {
continue;
}
ScreenRect isect;
if (!intersectRect(e.clip, pr, isect)) {
continue;
}
if (other < 32) visited |= (1u << other);
if (top < 64) stack[top++] = {other, e.depth + 1, isect};
}
}
} else {
// Camera room unknown - render ALL rooms as safety fallback.
// This guarantees no geometry disappears, at the cost of no culling.
for (int r = 0; r < roomCount; r++) if (r != catchAllIdx) renderRoom(r);
}
#ifdef PSXSPLASH_ROOM_DEBUG
// ================================================================
// Debug overlay: room status bars + portal outlines
// ================================================================
{
static const psyqo::Color roomColors[] = {
{.r = 255, .g = 50, .b = 50}, // R0: red
{.r = 50, .g = 255, .b = 50}, // R1: green
{.r = 50, .g = 50, .b = 255}, // R2: blue
{.r = 255, .g = 255, .b = 50}, // R3: yellow
{.r = 255, .g = 50, .b = 255}, // R4: magenta
{.r = 50, .g = 255, .b = 255}, // R5: cyan
{.r = 255, .g = 128, .b = 50}, // R6: orange
{.r = 128, .g = 128, .b = 255}, // R7: lavender
};
// Room status bars at top of screen
for (int r = 0; r < roomCount && r < 8; r++) {
bool rendered = (visited & (1u << r)) != 0;
bool isCamRoom = (r == cameraRoom);
auto& tile = balloc.allocateFragment<psyqo::Prim::FastFill>();
int16_t x = r * 18 + 2;
tile.primitive.setColor(rendered ?
roomColors[r] : psyqo::Color{.r = 40, .g = 40, .b = 40});
tile.primitive.rect = psyqo::Rect{
.a = {.x = x, .y = (int16_t)2},
.b = {.w = 14, .h = (int16_t)(isCamRoom ? 12 : 6)}
};
ot.insert(tile, 0);
}
// Portal outlines: project portal quad and draw edges as thin lines.
// Lines are drawn at OT front (depth 0) so they show through walls.
for (int p = 0; p < portalCount; p++) {
const PortalData& portal = portals[p];
// Compute portal corners in world space
int32_t rwx = ((int32_t)portal.rightX * portal.halfW) >> 12;
int32_t rwy = ((int32_t)portal.rightY * portal.halfW) >> 12;
int32_t rwz = ((int32_t)portal.rightZ * portal.halfW) >> 12;
int32_t uhx = ((int32_t)portal.upX * portal.halfH) >> 12;
int32_t uhy = ((int32_t)portal.upY * portal.halfH) >> 12;
int32_t uhz = ((int32_t)portal.upZ * portal.halfH) >> 12;
int32_t cx = portal.centerX, cy = portal.centerY, cz = portal.centerZ;
struct { int32_t wx, wy, wz; } corners[4] = {
{cx + rwx + uhx, cy + rwy + uhy, cz + rwz + uhz},
{cx - rwx + uhx, cy - rwy + uhy, cz - rwz + uhz},
{cx - rwx - uhx, cy - rwy - uhy, cz - rwz - uhz},
{cx + rwx - uhx, cy + rwy - uhy, cz + rwz - uhz},
};
// Project corners to screen
int16_t sx[4], sy[4];
bool vis[4];
int visCount = 0;
for (int i = 0; i < 4; i++) {
int32_t vx, vy, vz;
worldToCamera(corners[i].wx, corners[i].wy, corners[i].wz,
camX, camY, camZ, camRot, vx, vy, vz);
vis[i] = projectToScreen(vx, vy, vz, sx[i], sy[i]);
if (vis[i]) visCount++;
}
if (visCount < 2) continue; // Can't draw edges with <2 visible corners
// Draw each edge as a degenerate triangle (line).
// Color: orange for portal between visible rooms, dim for invisible.
bool portalActive = (visited & (1u << portal.roomA)) || (visited & (1u << portal.roomB));
psyqo::Color lineColor = portalActive ?
psyqo::Color{.r = 255, .g = 160, .b = 0} :
psyqo::Color{.r = 80, .g = 60, .b = 0};
for (int i = 0; i < 4; i++) {
int j = (i + 1) % 4;
if (!vis[i] || !vis[j]) continue;
// Clamp to screen to avoid GPU issues
int16_t x0 = sx[i], y0 = sy[i], x1 = sx[j], y1 = sy[j];
if (x0 < 0) x0 = 0; if (x0 > 319) x0 = 319;
if (y0 < 0) y0 = 0; if (y0 > 239) y0 = 239;
if (x1 < 0) x1 = 0; if (x1 > 319) x1 = 319;
if (y1 < 0) y1 = 0; if (y1 > 239) y1 = 239;
// Draw line as degenerate triangle (A=B=start, C=end gives a 1px line)
auto& tri = balloc.allocateFragment<psyqo::Prim::GouraudTriangle>();
tri.primitive.pointA.x = x0; tri.primitive.pointA.y = y0;
tri.primitive.pointB.x = x1; tri.primitive.pointB.y = y1;
tri.primitive.pointC.x = x1; tri.primitive.pointC.y = (int16_t)(y1 + 1);
tri.primitive.setColorA(lineColor);
tri.primitive.setColorB(lineColor);
tri.primitive.setColorC(lineColor);
tri.primitive.setOpaque();
ot.insert(tri, 0);
}
}
// Room AABB outlines: project the 8 corners of each room's AABB and draw edges.
for (int r = 0; r < roomCount - 1 && r < 8; r++) {
bool rendered = (visited & (1u << r)) != 0;
psyqo::Color boxColor = rendered ?
roomColors[r] : psyqo::Color{.r = 60, .g = 60, .b = 60};
const RoomData& rm = rooms[r];
int32_t bmin[3] = {rm.aabbMinX, rm.aabbMinY, rm.aabbMinZ};
int32_t bmax[3] = {rm.aabbMaxX, rm.aabbMaxY, rm.aabbMaxZ};
// 8 corners of the AABB
int16_t csx[8], csy[8];
bool cvis[8];
int cvisCount = 0;
for (int i = 0; i < 8; i++) {
int32_t wx = (i & 1) ? bmax[0] : bmin[0];
int32_t wy = (i & 2) ? bmax[1] : bmin[1];
int32_t wz = (i & 4) ? bmax[2] : bmin[2];
int32_t vx, vy, vz;
worldToCamera(wx, wy, wz, camX, camY, camZ, camRot, vx, vy, vz);
cvis[i] = projectToScreen(vx, vy, vz, csx[i], csy[i]);
if (cvis[i]) cvisCount++;
}
if (cvisCount < 2) continue;
// Draw 12 AABB edges
static const int edges[12][2] = {
{0,1},{2,3},{4,5},{6,7}, // X-axis edges
{0,2},{1,3},{4,6},{5,7}, // Y-axis edges
{0,4},{1,5},{2,6},{3,7}, // Z-axis edges
};
for (int e = 0; e < 12; e++) {
int a = edges[e][0], b = edges[e][1];
if (!cvis[a] || !cvis[b]) continue;
int16_t x0 = csx[a], y0 = csy[a], x1 = csx[b], y1 = csy[b];
if (x0 < 0) x0 = 0; if (x0 > 319) x0 = 319;
if (y0 < 0) y0 = 0; if (y0 > 239) y0 = 239;
if (x1 < 0) x1 = 0; if (x1 > 319) x1 = 319;
if (y1 < 0) y1 = 0; if (y1 > 239) y1 = 239;
auto& tri = balloc.allocateFragment<psyqo::Prim::GouraudTriangle>();
tri.primitive.pointA.x = x0; tri.primitive.pointA.y = y0;
tri.primitive.pointB.x = x1; tri.primitive.pointB.y = y1;
tri.primitive.pointC.x = x1; tri.primitive.pointC.y = (int16_t)(y1 + 1);
tri.primitive.setColorA(boxColor);
tri.primitive.setColorB(boxColor);
tri.primitive.setColorC(boxColor);
tri.primitive.setOpaque();
ot.insert(tri, 0);
}
}
}
#endif
m_gpu.getNextClear(clear.primitive, m_clearcolor);
m_gpu.chain(clear); m_gpu.chain(ot);
m_frameCount++;
}
void psxsplash::Renderer::VramUpload(const uint16_t* imageData, int16_t posX,
int16_t posY, int16_t width, int16_t height) {
psyqo::Rect uploadRect{.a = {.x = posX, .y = posY}, .b = {width, height}};
m_gpu.uploadToVRAM(imageData, uploadRect);
}