#ifdef BUILD_UI #include "ui/backends/LusBackend.h" #include #include #include "Companion.h" #include "ui/Theme.h" #include "ui/View.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include // The only translation unit that touches libultraship. namespace UI { namespace { // Hosts the Torch views inside LUS's gui draw loop. Draw() is overridden to // drive the active View directly; the View owns its own fullscreen window. class ViewHostWindow : public Ship::GuiWindow { public: explicit ViewHostWindow(std::shared_ptr views) : Ship::GuiWindow("gTorchViewer", true, "Torch"), mViews(std::move(views)) { } void InitElement() override { } void UpdateElement() override { } void DrawElement() override { } void Draw() override { // Single OS window: no platform viewports or docking. ImGui::GetIO().ConfigFlags &= ~(ImGuiConfigFlags_ViewportsEnable | ImGuiConfigFlags_DockingEnable); if (mViews != nullptr) { mViews->Render(); } } private: std::shared_ptr mViews; }; // Minimal control deck: the viewer takes no game input, but Context requires a // non-null deck. WriteToPad is a no-op. class ViewerControlDeck final : public Ship::ControlDeck { public: ViewerControlDeck() : Ship::ControlDeck({}, std::make_shared(), {}) { } void WriteToPad(void*) override { } }; // Suppress Fast3dGui's fullscreen "Main Game" window; there is no game scene // and it would cover the viewer. class ViewerGui final : public Fast::Fast3dGui { public: using Fast::Fast3dGui::Fast3dGui; void DrawGame() override { } }; class LusBackend final : public BaseBackend { public: void RunViewer(const std::shared_ptr& views) override { auto ctx = Ship::Context::CreateUninitializedInstance("Torch", "torch", "torch.cfg.json"); ctx->InitConfiguration(); ctx->InitConsoleVariables(); ctx->InitLogging(); ctx->InitControlDeck(std::make_shared()); // Mount the .o2r archives from the working directory so Fast3D can // resolve the resources referenced by the previewed assets. std::vector archives; std::error_code ec; for (const auto& entry : std::filesystem::directory_iterator(std::filesystem::current_path(), ec)) { if (entry.is_regular_file() && entry.path().extension() == ".o2r") { archives.push_back(entry.path().string()); } } #ifdef TORCH_LUS_SHADER_DIR // Mount the upstream LUS shaders last so they override any fork-modified // shaders shipped inside the game archives (last archive added wins). if (std::filesystem::exists(TORCH_LUS_SHADER_DIR)) { archives.push_back(TORCH_LUS_SHADER_DIR); } #endif ctx->InitResourceManager(archives, {}, 1); ctx->InitConsole(); ctx->InitAudio(Ship::AudioSettings{}); // Fast3D binary resource factories (DisplayList/Vertex/Texture/Matrix/Light). auto loader = ctx->GetResourceManager()->GetResourceLoader(); loader->RegisterResourceFactory(std::make_shared(), RESOURCE_FORMAT_BINARY, "DisplayList", static_cast(Fast::ResourceType::DisplayList), 0); loader->RegisterResourceFactory(std::make_shared(), RESOURCE_FORMAT_BINARY, "Vertex", static_cast(Fast::ResourceType::Vertex), 0); loader->RegisterResourceFactory(std::make_shared(), RESOURCE_FORMAT_BINARY, "Texture", static_cast(Fast::ResourceType::Texture), 0); loader->RegisterResourceFactory(std::make_shared(), RESOURCE_FORMAT_BINARY, "Texture", static_cast(Fast::ResourceType::Texture), 1); loader->RegisterResourceFactory(std::make_shared(), RESOURCE_FORMAT_BINARY, "Matrix", static_cast(Fast::ResourceType::Matrix), 0); loader->RegisterResourceFactory(std::make_shared(), RESOURCE_FORMAT_BINARY, "Light", static_cast(Fast::ResourceType::Light), 0); // GuiWindows must be added after InitWindow; their setup dereferences // Context::GetWindow(). auto gui = std::make_shared(std::vector>{}); auto window = std::make_shared(gui); ctx->InitWindow(window); ctx->InitEventSystem(); window->GetGui()->AddGuiWindow(std::make_shared(views)); window->SetTargetFps(60); // MSAA > 1 forces Fast3D into the offscreen game framebuffer // (mRendersToFb), which the previews rely on. window->SetMsaaLevel(4); window->SetRendererUCode(ucode_f3d); ApplyTorchTheme(); static Gfx emptyDl[] = { gsSPEndDisplayList() }; while (window->IsRunning()) { window->HandleEvents(); PumpAudio(); // Render the previous frame's preview requests; the gui draw blits // the results and queues the next batch. mRenderList.swap(mRequests); mRequests.clear(); Gfx* commands = BuildModelCommands(window); if (commands != nullptr) { window->DrawAndRunGraphicsCommands(commands, mMtxReplacements); } else { window->DrawAndRunGraphicsCommands(emptyDl, {}); } } } TextureHandle UploadRGBA8(const uint8_t* pixels, int width, int height) override { if (pixels == nullptr || width <= 0 || height <= 0) { return kInvalidTexture; } // Mirrors Fast3dGui::LoadGuiTexture's upload path. auto window = std::dynamic_pointer_cast(Ship::Context::GetInstance()->GetWindow()); if (window == nullptr) { return kInvalidTexture; } auto interpreter = window->GetInterpreterWeak().lock(); if (interpreter == nullptr) { return kInvalidTexture; } auto* api = interpreter->GetCurrentRenderingAPI(); if (api == nullptr) { return kInvalidTexture; } const uint32_t id = api->NewTexture(); api->SelectTexture(0, id); api->SetSamplerParameters(0, false, 0, 0); // nearest + clamp: crisp N64 texels api->UploadTexture(pixels, (uint32_t)width, (uint32_t)height); return api->GetTextureById((int)id); } void DrawPointCloud(uint64_t id, const std::vector& points, const ImVec2& topLeft, const ImVec2& size, const OrbitView& view) override { if (points.empty()) { return; } // Octahedron marker per point, sized against the cloud's bounds. float mn[3] = { 1e18f, 1e18f, 1e18f }, mx[3] = { -1e18f, -1e18f, -1e18f }; for (const auto& pt : points) { for (int k = 0; k < 3; ++k) { mn[k] = std::min(mn[k], pt.position[k]); mx[k] = std::max(mx[k], pt.position[k]); } } const float dx = mx[0] - mn[0], dy = mx[1] - mn[1], dz = mx[2] - mn[2]; const float r = std::max(std::sqrt(dx * dx + dy * dy + dz * dz) * 0.008f, 1.0f); static const int kFaces[8][3] = { { 0, 2, 4 }, { 2, 1, 4 }, { 1, 3, 4 }, { 3, 0, 4 }, { 2, 0, 5 }, { 1, 2, 5 }, { 3, 1, 5 }, { 0, 3, 5 }, }; constexpr size_t kMaxPoints = 4000; std::vector tris; tris.reserve(std::min(points.size(), kMaxPoints) * 24); for (size_t i = 0; i < points.size() && i < kMaxPoints; ++i) { const auto& pt = points[i]; const float c[6][3] = { { pt.position[0] + r, pt.position[1], pt.position[2] }, { pt.position[0] - r, pt.position[1], pt.position[2] }, { pt.position[0], pt.position[1], pt.position[2] + r }, { pt.position[0], pt.position[1], pt.position[2] - r }, { pt.position[0], pt.position[1] + r, pt.position[2] }, { pt.position[0], pt.position[1] - r, pt.position[2] }, }; for (const auto& face : kFaces) { for (int k = 0; k < 3; ++k) { PreviewVertex v = pt; std::memcpy(v.position, c[face[k]], sizeof(v.position)); tris.push_back(v); } } } // Thin ribbons between consecutive vertices; the buffer is laid out in // triangle order, so the links trace the mesh structure. const float lw = r * 0.35f; for (size_t i = 0; i + 1 < points.size() && i + 1 < kMaxPoints; ++i) { const float* a = points[i].position; const float* b = points[i + 1].position; float d[3] = { b[0] - a[0], b[1] - a[1], b[2] - a[2] }; const float len = std::sqrt(d[0] * d[0] + d[1] * d[1] + d[2] * d[2]); if (len < 0.0001f) { continue; } unsigned char color[4]; for (int k = 0; k < 4; ++k) { color[k] = (unsigned char)((points[i].color[k] + points[i + 1].color[k]) / 3); } color[3] = 255; float u[3] = { d[2] / len * lw, 0.0f, -d[0] / len * lw }; if (std::fabs(u[0]) + std::fabs(u[2]) < 0.0001f) { u[0] = lw; } const float v2[3] = { (d[1] * u[2] - d[2] * u[1]) / len, (d[2] * u[0] - d[0] * u[2]) / len, (d[0] * u[1] - d[1] * u[0]) / len }; const float* offs[2] = { u, v2 }; for (const auto* o : offs) { const float q0[3] = { a[0] - o[0], a[1] - o[1], a[2] - o[2] }; const float q1[3] = { a[0] + o[0], a[1] + o[1], a[2] + o[2] }; const float q2[3] = { b[0] + o[0], b[1] + o[1], b[2] + o[2] }; const float q3[3] = { b[0] - o[0], b[1] - o[1], b[2] - o[2] }; const float* quads[2][3] = { { q0, q1, q2 }, { q0, q2, q3 } }; for (const auto& q : quads) { for (int k = 0; k < 3; ++k) { PreviewVertex v{}; std::memcpy(v.position, q[k], sizeof(v.position)); std::memcpy(v.color, color, 4); tris.push_back(v); } } } } DrawTriangles("points://" + std::to_string(id), tris, topLeft, size, view); } void DrawTriangles(const std::string& key, const std::vector& tris, const ImVec2& topLeft, const ImVec2& size, const OrbitView& view) override { if (tris.size() < 3) { return; } RawModel& raw = mRawModels[key]; const uint64_t hash = HashBytes(tris.data(), tris.size() * sizeof(PreviewVertex)); if (raw.hash != hash) { BuildRawModel(raw, tris, hash); mBoundsCache[key] = raw.bounds; for (auto& slot : mFbPool) { if (slot.owner == key) { slot.lastPartsHash = 0; } } } ModelPart part; part.resource = key; static const float kIdentity[4][4] = { { 1, 0, 0, 0 }, { 0, 1, 0, 0 }, { 0, 0, 1, 0 }, { 0, 0, 0, 1 } }; std::memcpy(part.mtx, kIdentity, sizeof(kIdentity)); DrawModelParts(key, { part }, topLeft, size, view); } void DrawModel(const std::string& resourceName, const ImVec2& topLeft, const ImVec2& size, const OrbitView& view) override { // A single display list is just a one-part model with an identity transform. static const float kIdentity[4][4] = { { 1, 0, 0, 0 }, { 0, 1, 0, 0 }, { 0, 0, 1, 0 }, { 0, 0, 0, 1 } }; ModelPart part; part.resource = resourceName; std::memcpy(part.mtx, kIdentity, sizeof(kIdentity)); DrawModelParts(resourceName, { part }, topLeft, size, view); } void DrawModelParts(const std::string& key, const std::vector& parts, const ImVec2& topLeft, const ImVec2& size, const OrbitView& view) override { if (size.x < 1.0f || size.y < 1.0f || parts.empty()) { return; } // Queue for next frame's render pass, then blit this model's framebuffer // (sized to its rect, so it fills the canvas centered). mRequests.push_back({ key, parts, topLeft, size, view }); auto it = mNameToFb.find(key); if (it == mNameToFb.end()) { return; } auto window = std::dynamic_pointer_cast(Ship::Context::GetInstance()->GetWindow()); if (window == nullptr) { return; } auto interp = window->GetInterpreterWeak().lock(); if (interp == nullptr) { return; } auto* api = interp->GetCurrentRenderingAPI(); if (api == nullptr) { return; } void* tex = api->GetFramebufferTextureId(it->second); if (tex == nullptr) { return; } // Metal/D3D render targets are top-down (match ImGui), OpenGL bottom-up. const bool flipV = api->GetClipParameters().invertY; const ImVec2 uvMin(0.0f, flipV ? 1.0f : 0.0f); const ImVec2 uvMax(1.0f, flipV ? 0.0f : 1.0f); ImGui::GetWindowDrawList()->AddImage(reinterpret_cast(tex), topLeft, ImVec2(topLeft.x + size.x, topLeft.y + size.y), uvMin, uvMax); } void SetPreviewBackdrop(const std::string& key, const uint8_t* rgba, int width, int height) override { if (rgba == nullptr || width <= 0 || height <= 0) { mBackdrops.erase(key); return; } // Texel buffers are content-keyed and never freed: the interpreter's // texture cache keys on the data address, so reusing one buffer for a // different image would serve the stale GPU texture. const uint64_t hash = HashBytes(rgba, (size_t)width * height * 4, 1469598103934665603ull + width * 131 + height); auto& texels = mBackdropTexelCache[hash]; if (texels.empty()) { // N64 RGBA16 texels (big-endian 5551) for the texture-rectangle strips. texels.resize((size_t)width * height * 2); for (int i = 0; i < width * height; ++i) { const uint16_t v = (uint16_t)(((rgba[i * 4 + 0] >> 3) << 11) | ((rgba[i * 4 + 1] >> 3) << 6) | ((rgba[i * 4 + 2] >> 3) << 1) | 1); texels[i * 2 + 0] = (uint8_t)(v >> 8); texels[i * 2 + 1] = (uint8_t)(v & 0xFF); } } Backdrop& bd = mBackdrops[key]; bd.texels = &texels; bd.w = width; bd.h = height; bd.generation = hash; } bool RegisterGameDList(const std::string& name, const UI::GfxBundle& bundle, uint32_t entryOffset) override { if (mGameGfx.count(name) != 0) { return true; } if (bundle.blob == nullptr || bundle.blob->empty()) { return false; } auto bit = mGameBundles.find(bundle.blob.get()); if (bit == mGameBundles.end()) { auto gb = std::make_shared(); gb->blob = bundle.blob; gb->vtxBase = bundle.vtxBase; gb->vtxSize = bundle.vtxSize; // Two passes: allocate every dlist first so G_DL can cross-reference. for (const auto& dl : bundle.dlists) { gb->dlists[dl.offset].resize(dl.words.size() / 2); } uint8_t* blobData = gb->blob->data(); const size_t blobSize = gb->blob->size(); for (const auto& dl : bundle.dlists) { auto& out = gb->dlists[dl.offset]; for (size_t i = 0; i + 1 < dl.words.size(); i += 2) { const uint32_t w0 = dl.words[i]; const uint32_t w1 = dl.words[i + 1]; const uint8_t op = w0 >> 24; Gfx& g = out[i / 2]; g.words.w0 = w0; g.words.w1 = w1; const auto noop = [&g] { g.words.w0 = 0; g.words.w1 = 0; }; if (op == 0x01) { // G_VTX: vtxBase-relative byte offset const uint32_t numv = (w0 >> 12) & 0xFF; const uint64_t off = (uint64_t)gb->vtxBase + w1; if (numv != 0 && off + (uint64_t)numv * 16 <= blobSize) { g.words.w1 = (uintptr_t)(blobData + off); gb->meta[dl.offset].vtxSpans.emplace_back((uint32_t)off, numv); } else { noop(); } } else if (op == 0xDE) { // G_DL: blob offset of another dlist auto tit = gb->dlists.find(w1); if (tit != gb->dlists.end()) { g.words.w1 = (uintptr_t)tit->second.data(); gb->meta[dl.offset].children.push_back(w1); } else { g.words.w0 = (uintptr_t)0xDF << 24; // missing target: end g.words.w1 = 0; } } else if (op == 0xFD) { // G_SETTIMG if (w1 < blobSize) { g.words.w1 = (uintptr_t)(blobData + w1); } else { noop(); } } else if (op == 0x04 || op == 0xDA || op == 0xDC) { // BRANCH_Z / G_MTX / G_MOVEMEM carry addresses we can't // resolve; drop them rather than dereference garbage. noop(); } } } bit = mGameBundles.emplace(bundle.blob.get(), std::move(gb)).first; } auto& gb = bit->second; auto eit = gb->dlists.find(entryOffset); if (eit == gb->dlists.end() || eit->second.empty()) { return false; } // Bounds from the vertices this dlist (and its G_DL children) actually // loads, so the camera frames each part instead of the whole table. if (mBoundsCache.find(name) == mBoundsCache.end()) { float mn[3] = { 1e18f, 1e18f, 1e18f }, mx[3] = { -1e18f, -1e18f, -1e18f }; bool any = false; std::vector queue{ entryOffset }; std::unordered_set seen{ entryOffset }; while (!queue.empty()) { const uint32_t off = queue.back(); queue.pop_back(); const auto mit = gb->meta.find(off); if (mit == gb->meta.end()) { continue; } for (const auto& [vtxOff, numv] : mit->second.vtxSpans) { for (uint32_t i = 0; i < numv; ++i) { const int16_t* pos = (const int16_t*)(gb->blob->data() + vtxOff + i * 16); for (int a = 0; a < 3; ++a) { mn[a] = std::min(mn[a], (float)pos[a]); mx[a] = std::max(mx[a], (float)pos[a]); } any = true; } } for (const uint32_t child : mit->second.children) { if (seen.insert(child).second) { queue.push_back(child); } } } // Fallback: the blob's whole vertex table. if (!any && gb->vtxSize >= 16) { const uint8_t* base = gb->blob->data() + gb->vtxBase; const size_t count = std::min(gb->vtxSize / 16, (gb->blob->size() - gb->vtxBase) / 16); for (size_t i = 0; i < count; ++i) { const int16_t* pos = (const int16_t*)(base + i * 16); for (int a = 0; a < 3; ++a) { mn[a] = std::min(mn[a], (float)pos[a]); mx[a] = std::max(mx[a], (float)pos[a]); } any = true; } } if (any) { ModelBounds b; b.cx = (mn[0] + mx[0]) * 0.5f; b.cy = (mn[1] + mx[1]) * 0.5f; b.cz = (mn[2] + mx[2]) * 0.5f; const float dx = mx[0] - mn[0], dy = mx[1] - mn[1], dz = mx[2] - mn[2]; b.radius = std::max(0.5f * std::sqrt(dx * dx + dy * dy + dz * dz), 1.0f); mBoundsCache.emplace(name, b); } } mGameGfx[name] = { gb, eit->second.data() }; return true; } bool PlaySamples(const int16_t* frames, size_t frameCount, int sampleRate, int channels) override { if (frames == nullptr || frameCount == 0 || sampleRate <= 0 || channels <= 0) { return false; } auto audio = Ship::Context::GetInstance()->GetAudio(); if (audio == nullptr || audio->GetAudioPlayer() == nullptr || !audio->GetAudioPlayer()->IsInitialized()) { return false; } mAudioPcm.assign(frames, frames + frameCount * channels); mAudioSrcRate = sampleRate; mAudioSrcChannels = channels; mAudioTotalFrames = frameCount; mAudioPos = 0.0; return true; } void StopAudio() override { mAudioPcm.clear(); mAudioTotalFrames = 0; mAudioPos = 0.0; } float AudioProgress() override { if (mAudioTotalFrames == 0) { return -1.0f; } return (float)(mAudioPos / (double)mAudioTotalFrames); } void SeekAudio(float progress) override { if (mAudioTotalFrames != 0) { mAudioPos = std::clamp((double)progress, 0.0, 1.0) * (double)mAudioTotalFrames; } } void SetAudioVolume(float volume) override { mAudioVolume = std::clamp(volume, 0.0f, 1.0f); } float GetAudioVolume() override { return mAudioVolume; } void SetAudioSpeed(float speed) override { mAudioSpeed = std::clamp(speed, 0.25f, 4.0f); } float GetAudioSpeed() override { return mAudioSpeed; } private: // out = a * b in row-vector convention (a applied first). static void MulMtxF(MtxF& out, const float a[4][4], const MtxF& b) { for (int i = 0; i < 4; ++i) { for (int j = 0; j < 4; ++j) { out.mf[i][j] = a[i][0] * b.mf[0][j] + a[i][1] * b.mf[1][j] + a[i][2] * b.mf[2][j] + a[i][3] * b.mf[3][j]; } } } // Row-vector (v' = v*M) perspective + look-at, matching N64/Fast3D convention. static void Perspective(MtxF& m, float fovyDeg, float aspect, float n, float f) { for (auto& row : m.mf) { for (auto& v : row) { v = 0.0f; } } const float cot = 1.0f / std::tan(fovyDeg * (float)M_PI / 360.0f); m.mf[0][0] = cot / aspect; m.mf[1][1] = cot; m.mf[2][2] = (n + f) / (n - f); m.mf[2][3] = -1.0f; m.mf[3][2] = 2.0f * n * f / (n - f); } static void LookAt(MtxF& m, float ex, float ey, float ez, float ax, float ay, float az) { float zx = ex - ax, zy = ey - ay, zz = ez - az; float zl = std::sqrt(zx * zx + zy * zy + zz * zz); if (zl == 0.0f) { zl = 1.0f; } zx /= zl; zy /= zl; zz /= zl; // x = up(0,1,0) x z, normalized float xx = zz, xy = 0.0f, xz = -zx; float xl = std::sqrt(xx * xx + xy * xy + xz * xz); if (xl == 0.0f) { xl = 1.0f; } xx /= xl; xy /= xl; xz /= xl; // y = z x x const float yx = zy * xz - zz * xy; const float yy = zz * xx - zx * xz; const float yz = zx * xy - zy * xx; m.mf[0][0] = xx; m.mf[0][1] = yx; m.mf[0][2] = zx; m.mf[0][3] = 0.0f; m.mf[1][0] = xy; m.mf[1][1] = yy; m.mf[1][2] = zy; m.mf[1][3] = 0.0f; m.mf[2][0] = xz; m.mf[2][1] = yz; m.mf[2][2] = zz; m.mf[2][3] = 0.0f; m.mf[3][0] = -(xx * ex + xy * ey + xz * ez); m.mf[3][1] = -(yx * ex + yy * ey + yz * ez); m.mf[3][2] = -(zx * ex + zy * ey + zz * ez); m.mf[3][3] = 1.0f; } // Accumulate vertex bounds across a display list, following vertex loads // (G_VTX_OTR_HASH) and sub-DL branches (G_DL_OTR_HASH). The resource hash // sits in the Gfx word after the command. void AccumulateBounds(const std::shared_ptr& dl, const std::shared_ptr& rm, float mn[3], float mx[3], bool& any, std::unordered_set& visited, int depth) { if (dl == nullptr || depth > 24) { return; } auto am = rm->GetArchiveManager(); const auto& instr = dl->Instructions; for (size_t i = 0; i + 1 < instr.size(); ++i) { const int8_t op = (int8_t)(instr[i].words.w0 >> 24); const bool isVtx = op == (int8_t)0x32; const bool isDl = op == (int8_t)0x31; if (!isVtx && !isDl) { continue; } const uint64_t hash = ((uint64_t)(uint32_t)instr[i + 1].words.w0 << 32) | (uint32_t)instr[i + 1].words.w1; ++i; // the hash occupies the next Gfx word const std::string* path = am ? am->HashToString(hash) : nullptr; if (path == nullptr || !visited.insert(*path).second) { continue; } if (isVtx) { auto vtx = std::static_pointer_cast(rm->LoadResource(*path)); if (vtx == nullptr) { continue; } for (const auto& v : vtx->VertexList) { for (int k = 0; k < 3; ++k) { const float val = (float)v.v.ob[k]; mn[k] = std::min(mn[k], val); mx[k] = std::max(mx[k], val); } any = true; } } else { // branch into the sub-display-list auto sub = std::static_pointer_cast(rm->LoadResource(*path)); AccumulateBounds(sub, rm, mn, mx, any, visited, depth + 1); } } } // Vertex bounds of a display list, used to frame it (centered, fit to view). struct ModelBounds { float cx = 0.0f, cy = 0.0f, cz = 0.0f, radius = 150.0f; }; ModelBounds ComputeBounds(const std::shared_ptr& dl, const std::shared_ptr& rm) { ModelBounds b; if (dl == nullptr) { return b; } float mn[3] = { 1e18f, 1e18f, 1e18f }; float mx[3] = { -1e18f, -1e18f, -1e18f }; bool any = false; std::unordered_set visited; AccumulateBounds(dl, rm, mn, mx, any, visited, 0); if (any) { b.cx = (mn[0] + mx[0]) * 0.5f; b.cy = (mn[1] + mx[1]) * 0.5f; b.cz = (mn[2] + mx[2]) * 0.5f; const float dx = mx[0] - mn[0], dy = mx[1] - mn[1], dz = mx[2] - mn[2]; b.radius = std::max(0.5f * std::sqrt(dx * dx + dy * dy + dz * dz), 1.0f); } if (!any || b.radius > 50000.0f || !std::isfinite(b.radius)) { b = ModelBounds{}; } return b; } // Union of each part's bounds transformed by its matrix. Parts far larger // than the rest (sky domes, backdrops) are excluded from framing so the // camera isn't pushed inside them. ModelBounds ComputePartsBounds(const std::vector& parts, const std::shared_ptr& rm) { struct Sphere { float c[3]; float r; }; std::vector spheres; spheres.reserve(parts.size()); for (const auto& part : parts) { auto bit = mBoundsCache.find(part.resource); if (bit == mBoundsCache.end() && mGameGfx.count(part.resource) == 0) { auto dl = std::static_pointer_cast(rm->LoadResource(part.resource)); if (dl == nullptr) { continue; } bit = mBoundsCache.emplace(part.resource, ComputeBounds(dl, rm)).first; } if (bit == mBoundsCache.end()) { continue; } const ModelBounds& pb = bit->second; const auto& m = part.mtx; // Billboard matrices are anchor-relative; shift by the anchor. const float ax = part.billboard ? part.anchor[0] : 0.0f; const float ay = part.billboard ? part.anchor[1] : 0.0f; const float az = part.billboard ? part.anchor[2] : 0.0f; Sphere s; s.c[0] = pb.cx * m[0][0] + pb.cy * m[1][0] + pb.cz * m[2][0] + m[3][0] + ax; s.c[1] = pb.cx * m[0][1] + pb.cy * m[1][1] + pb.cz * m[2][1] + m[3][1] + ay; s.c[2] = pb.cx * m[0][2] + pb.cy * m[1][2] + pb.cz * m[2][2] + m[3][2] + az; float scale = 0.0f; for (int r = 0; r < 3; ++r) { scale = std::max(scale, std::sqrt(m[r][0] * m[r][0] + m[r][1] * m[r][1] + m[r][2] * m[r][2])); } s.r = pb.radius * std::max(scale, 0.001f); spheres.push_back(s); } float cutoff = 1e18f; if (spheres.size() >= 4) { std::vector radii; radii.reserve(spheres.size()); for (const auto& s : spheres) { radii.push_back(s.r); } std::nth_element(radii.begin(), radii.begin() + radii.size() / 2, radii.end()); cutoff = std::max(radii[radii.size() / 2] * 6.0f, 1.0f); } float mn[3] = { 1e18f, 1e18f, 1e18f }; float mx[3] = { -1e18f, -1e18f, -1e18f }; bool any = false; for (const auto& s : spheres) { if (s.r > cutoff) { continue; } for (int a = 0; a < 3; ++a) { mn[a] = std::min(mn[a], s.c[a] - s.r); mx[a] = std::max(mx[a], s.c[a] + s.r); } any = true; } ModelBounds b; if (any) { b.cx = (mn[0] + mx[0]) * 0.5f; b.cy = (mn[1] + mx[1]) * 0.5f; b.cz = (mn[2] + mx[2]) * 0.5f; const float dx = mx[0] - mn[0], dy = mx[1] - mn[1], dz = mx[2] - mn[2]; b.radius = std::max(0.5f * std::sqrt(dx * dx + dy * dy + dz * dz), 1.0f); } if (!any || b.radius > 50000.0f || !std::isfinite(b.radius)) { b = ModelBounds{}; } return b; } // Render target for one model preview, pooled and reassigned by name as // rows scroll. struct FbSlot { int fbId = -1; uint32_t w = 0, h = 0; std::string owner; // model currently rendered into this framebuffer Vp vp{}; Mtx proj{}; // stable address: key into the Mtx->MtxF replacement map // Last rendered state; the framebuffer persists until these change. bool rendered = false; OrbitView lastView{}; uint64_t lastPartsHash = 0; }; static bool SameView(const OrbitView& a, const OrbitView& b) { return a.yaw == b.yaw && a.pitch == b.pitch && a.zoom == b.zoom && a.panX == b.panX && a.panY == b.panY; } static uint64_t HashBytes(const void* data, size_t n, uint64_t h = 1469598103934665603ull) { const auto* p = static_cast(data); for (size_t i = 0; i < n; ++i) { h ^= p[i]; h *= 1099511628211ull; } return h; } // In-memory geometry for DrawTriangles: vertex-colored, unlit. struct RawModel { std::vector vtx; std::vector gfx; ModelBounds bounds; uint64_t hash = 0; }; void BuildRawModel(RawModel& raw, const std::vector& tris, uint64_t hash) { static_assert(sizeof(Fast::F3DVtx) == 16, "F3D vertex encoding assumes 16-byte vertices"); const size_t triCount = tris.size() / 3; raw.hash = hash; raw.vtx.clear(); raw.gfx.clear(); raw.vtx.reserve(triCount * 3); float mn[3] = { 1e18f, 1e18f, 1e18f }, mx[3] = { -1e18f, -1e18f, -1e18f }; for (size_t i = 0; i < triCount * 3; ++i) { const PreviewVertex& src = tris[i]; Fast::F3DVtx v{}; for (int k = 0; k < 3; ++k) { v.v.ob[k] = (short)std::clamp(src.position[k], -32768.0f, 32767.0f); mn[k] = std::min(mn[k], src.position[k]); mx[k] = std::max(mx[k], src.position[k]); } std::memcpy(v.v.cn, src.color, 4); raw.vtx.push_back(v); } raw.bounds.cx = (mn[0] + mx[0]) * 0.5f; raw.bounds.cy = (mn[1] + mx[1]) * 0.5f; raw.bounds.cz = (mn[2] + mx[2]) * 0.5f; const float dx = mx[0] - mn[0], dy = mx[1] - mn[1], dz = mx[2] - mn[2]; raw.bounds.radius = std::max(0.5f * std::sqrt(dx * dx + dy * dy + dz * dz), 1.0f); // Vertex colors: no lighting, shade-only combiner. F3D loads at most 16 // vertices, so batch 5 triangles per load (raw F3D G_VTX/G_TRI1 words; // the gbi.h macros emit F3DEX encodings the f3d handlers misread). raw.gfx.reserve(triCount * 3 + 8); Gfx g{}; gSPClearGeometryMode(&g, G_LIGHTING); raw.gfx.push_back(g); gDPSetCombineMode(&g, G_CC_SHADE, G_CC_SHADE); raw.gfx.push_back(g); for (size_t base = 0; base < triCount * 3; base += 15) { const size_t n = std::min(15, triCount * 3 - base); g.words.w0 = ((uintptr_t)0x04 << 24) | (uintptr_t)(n * sizeof(Fast::F3DVtx)); // G_VTX, v0=0 g.words.w1 = (uintptr_t)&raw.vtx[base]; raw.gfx.push_back(g); for (size_t t = 0; t + 2 < n; t += 3) { g.words.w0 = (uintptr_t)0xBF << 24; // G_TRI1, indices * 10 g.words.w1 = ((uintptr_t)(t * 10) << 16) | ((uintptr_t)((t + 1) * 10) << 8) | (uintptr_t)((t + 2) * 10); raw.gfx.push_back(g); } } gSPEndDisplayList(&g); raw.gfx.push_back(g); } // FNV-1a over the parts' resources, layers and transforms. static uint64_t HashParts(const std::vector& parts) { uint64_t h = 1469598103934665603ull; const auto mix = [&h](const void* data, size_t n) { const auto* p = static_cast(data); for (size_t i = 0; i < n; ++i) { h ^= p[i]; h *= 1099511628211ull; } }; for (const auto& part : parts) { h = h * 1099511628211ULL + (part.texture != nullptr ? part.texture->rasterOffset + 1 : 0); mix(part.resource.data(), part.resource.size()); mix(&part.layer, sizeof(part.layer)); mix(part.mtx, sizeof(part.mtx)); mix(&part.billboard, sizeof(part.billboard)); mix(part.anchor, sizeof(part.anchor)); mix(&part.gameShade, sizeof(part.gameShade)); mix(&part.unlit, sizeof(part.unlit)); mix(&part.fullAmbient, sizeof(part.fullAmbient)); } // Light edits must re-render every framebuffer. const PreviewLighting& light = GetPreviewLighting(); mix(&light.enabled, sizeof(light.enabled)); mix(light.ambient, sizeof(light.ambient)); mix(light.color, sizeof(light.color)); mix(light.position, sizeof(light.position)); mix(&light.intensity, sizeof(light.intensity)); mix(&light.falloff, sizeof(light.falloff)); const PreviewAtmosphere& atmo = GetPreviewAtmosphere(); mix(&atmo.fogEnabled, sizeof(atmo.fogEnabled)); mix(atmo.fogColor, sizeof(atmo.fogColor)); mix(&atmo.fogStart, sizeof(atmo.fogStart)); mix(&atmo.fogEnd, sizeof(atmo.fogEnd)); return h; } // Reuse the slot owned by `name`, else one whose owner isn't visible, else // any free slot; -1 when exhausted. int AssignSlot(const std::string& name, const std::vector& used, const std::unordered_set& visible) { for (size_t i = 0; i < mFbPool.size(); ++i) { if (!used[i] && mFbPool[i].fbId >= 0 && mFbPool[i].owner == name) { return (int)i; } } for (size_t i = 0; i < mFbPool.size(); ++i) { if (!used[i] && (mFbPool[i].owner.empty() || visible.count(mFbPool[i].owner) == 0)) { return (int)i; } } for (size_t i = 0; i < mFbPool.size(); ++i) { if (!used[i]) { return (int)i; } } return -1; } void PumpAudio() { if (mAudioTotalFrames == 0) { return; } auto audio = Ship::Context::GetInstance()->GetAudio(); auto player = audio != nullptr ? audio->GetAudioPlayer() : nullptr; if (player == nullptr || !player->IsInitialized()) { return; } const int outCh = player->GetNumOutputChannels(); const int need = player->GetDesiredBuffered() - player->Buffered(); if (need <= 0) { return; } const int chunk = std::min(need, 4096); const double step = (double)mAudioSrcRate * mAudioSpeed / (double)player->GetSampleRate(); std::vector out; out.reserve((size_t)chunk * outCh); for (int i = 0; i < chunk; ++i) { const size_t frame = (size_t)mAudioPos; if (frame >= mAudioTotalFrames) { break; } const int16_t l = (int16_t)(mAudioPcm[frame * mAudioSrcChannels] * mAudioVolume); const int16_t r = (int16_t)((mAudioSrcChannels > 1 ? mAudioPcm[frame * mAudioSrcChannels + 1] : mAudioPcm[frame * mAudioSrcChannels]) * mAudioVolume); out.push_back(l); out.push_back(r); for (int c = 2; c < outCh; ++c) { out.push_back(0); } mAudioPos += step; } if (!out.empty()) { player->Play((const uint8_t*)out.data(), out.size() * sizeof(int16_t)); } if ((size_t)mAudioPos >= mAudioTotalFrames) { StopAudio(); } } // Emits the tile setup for a part texture using the game's conventions // (texel wrap masks from dimensions, CI palettes via TLUT). static int ILog2(uint16_t v) { int m = 0; while ((1 << (m + 1)) <= v) { m++; } return m; } Gfx* EmitPartTexture(Gfx* p, const UI::PartTexture& t, bool fog) { uint8_t* raster = t.blob->data() + t.rasterOffset; const int maskS = ILog2(t.width); const int maskT = ILog2(t.height); const bool aux = t.auxMode != 0 && t.auxRasterOffset != 0 && t.auxWidth != 0 && t.auxHeight != 0; gDPPipeSync(p++); gSPTexture(p++, 0xFFFF, 0xFFFF, 0, G_TX_RENDERTILE, G_ON); // pmret SolidCombineModes, TINT_COMBINE_NONE / TINT_COMBINE_FOG // columns (fog moves the second cycle to PASS so blender cycle 1 can // fog the combined color). if (aux) { if (fog) { // G_CC_INTERFERENCE, PM_CC2_MULTIPLY_SHADE gDPSetCombineLERP(p++, TEXEL0, 0, TEXEL1, 0, TEXEL0, 0, TEXEL1, 0, COMBINED, 0, SHADE, 0, 0, 0, 0, COMBINED); } else { // PM_CC_ALT_INTERFERENCE, G_CC_MODULATEIA2 gDPSetCombineLERP(p++, TEXEL1, 0, TEXEL0, 0, TEXEL1, 0, TEXEL0, 0, COMBINED, 0, SHADE, 0, COMBINED, 0, SHADE, 0); } } else if (t.combine == 1) { if (fog) { gDPSetCombineMode(p++, G_CC_BLENDRGBA, G_CC_PASS2); } else { gDPSetCombineMode(p++, G_CC_BLENDRGBA, G_CC_BLENDRGBA); } } else if (t.combine == 2) { if (fog) { gDPSetCombineMode(p++, G_CC_DECALRGBA, G_CC_PASS2); } else { gDPSetCombineMode(p++, G_CC_DECALRGBA, G_CC_DECALRGBA); } } else { if (fog) { gDPSetCombineMode(p++, G_CC_MODULATEIDECALA, G_CC_PASS2); } else { gDPSetCombineMode(p++, G_CC_MODULATEIA, G_CC_MODULATEIA); } } const bool auxCi = aux && t.auxFmt == G_IM_FMT_CI; if (t.fmt == G_IM_FMT_CI || auxCi) { gDPSetTextureLUT(p++, G_TT_RGBA16); if (t.fmt == G_IM_FMT_CI) { uint8_t* pal = t.blob->data() + t.paletteOffset; if (t.siz == G_IM_SIZ_8b) { gDPLoadTLUT_pal256(p++, pal); } else { gDPLoadTLUT_pal16(p++, 0, pal); } } // Independent aux palette rides in slot 1 (shared-raster aux reuses // the main palette). if (auxCi && t.auxMode == 3 && t.auxPaletteOffset != 0 && t.auxSiz != G_IM_SIZ_8b) { gDPLoadTLUT_pal16(p++, 1, t.blob->data() + t.auxPaletteOffset); } } else { gDPSetTextureLUT(p++, G_TT_NONE); } switch (t.siz) { case G_IM_SIZ_4b: gDPLoadTextureBlock_4b(p++, raster, t.fmt, t.width, t.height, 0, t.cmS, t.cmT, maskS, maskT, G_TX_NOLOD, G_TX_NOLOD); break; case G_IM_SIZ_8b: gDPLoadTextureBlock(p++, raster, t.fmt, G_IM_SIZ_8b, t.width, t.height, 0, t.cmS, t.cmT, maskS, maskT, G_TX_NOLOD, G_TX_NOLOD); break; case G_IM_SIZ_32b: gDPLoadTextureBlock(p++, raster, t.fmt, G_IM_SIZ_32b, t.width, t.height, 0, t.cmS, t.cmT, maskS, maskT, G_TX_NOLOD, G_TX_NOLOD); break; default: gDPLoadTextureBlock(p++, raster, t.fmt, G_IM_SIZ_16b, t.width, t.height, 0, t.cmS, t.cmT, maskS, maskT, G_TX_NOLOD, G_TX_NOLOD); break; } if (aux) { // Second tile into the upper TMEM slot (any nonzero tmem selects // the interpreter's slot 1); TEXEL1 blends it in cycle 1. uint8_t* auxRaster = t.blob->data() + t.auxRasterOffset; const uint32_t mainBytes = ((uint32_t)t.width * t.height * (4u << t.siz)) / 8; const uint32_t tmem = std::max(1u, (mainBytes + 7) >> 3); const int auxPalIdx = t.auxMode == 3 && auxCi && t.auxSiz != G_IM_SIZ_8b ? 1 : 0; const int auxMaskS = ILog2(t.auxWidth); const int auxMaskT = ILog2(t.auxHeight); switch (t.auxSiz) { case G_IM_SIZ_4b: gDPLoadMultiBlock_4b(p++, auxRaster, tmem, 1, t.auxFmt, t.auxWidth, t.auxHeight, auxPalIdx, t.auxCmS, t.auxCmT, auxMaskS, auxMaskT, G_TX_NOLOD, G_TX_NOLOD); break; case G_IM_SIZ_8b: gDPLoadMultiBlock(p++, auxRaster, tmem, 1, t.auxFmt, G_IM_SIZ_8b, t.auxWidth, t.auxHeight, auxPalIdx, t.auxCmS, t.auxCmT, auxMaskS, auxMaskT, G_TX_NOLOD, G_TX_NOLOD); break; case G_IM_SIZ_32b: gDPLoadMultiBlock(p++, auxRaster, tmem, 1, t.auxFmt, G_IM_SIZ_32b, t.auxWidth, t.auxHeight, auxPalIdx, t.auxCmS, t.auxCmT, auxMaskS, auxMaskT, G_TX_NOLOD, G_TX_NOLOD); break; default: gDPLoadMultiBlock(p++, auxRaster, tmem, 1, t.auxFmt, G_IM_SIZ_16b, t.auxWidth, t.auxHeight, auxPalIdx, t.auxCmS, t.auxCmT, auxMaskS, auxMaskT, G_TX_NOLOD, G_TX_NOLOD); break; } } return p; } // Assigns a framebuffer to every visible model and builds a command list // rendering the one whose content changed this frame. Returns nullptr if // nothing needs rendering. Gfx* BuildModelCommands(const std::shared_ptr& window) { mMtxReplacements.clear(); if (mRenderList.empty()) { return nullptr; } auto rm = Ship::Context::GetInstance()->GetResourceManager(); auto interp = window->GetInterpreterWeak().lock(); if (rm == nullptr || interp == nullptr) { return nullptr; } auto* api = interp->GetCurrentRenderingAPI(); if (api == nullptr) { return nullptr; } if ((int)mFbPool.size() != kFbPoolSize) { mFbPool.resize(kFbPoolSize); } std::unordered_set visible; for (const auto& r : mRenderList) { visible.insert(r.name); } std::vector used(mFbPool.size(), false); // Phase 1: assign framebuffer slots and pick one dirty model to render. // The Metal backend only presents the first offscreen framebuffer drawn // per frame, so the rest keep their persisted render. int targetIdx = -1; int targetReqIdx = -1; std::vector> dirtySlots; // (pool idx, request idx) for (size_t ri = 0; ri < mRenderList.size(); ++ri) { const ModelRequest& req = mRenderList[ri]; const int idx = AssignSlot(req.name, used, visible); if (idx < 0) { continue; } used[idx] = true; FbSlot& slot = mFbPool[idx]; const bool newOwner = slot.owner != req.name; slot.owner = req.name; uint32_t w = std::max(1u, (uint32_t)req.size.x); uint32_t h = std::max(1u, (uint32_t)req.size.y); // Clamp to a sane max (preserving aspect): a giant rect would overflow // the int16 viewport scale and exceed GPU texture limits. constexpr uint32_t kMaxFb = 2048; if (w > kMaxFb || h > kMaxFb) { const float s = (float)kMaxFb / (float)std::max(w, h); w = std::max(1u, (uint32_t)(w * s)); h = std::max(1u, (uint32_t)(h * s)); } if (slot.fbId < 0) { slot.fbId = interp->CreateFrameBuffer(w, h, w, h, 0, false); slot.w = w; slot.h = h; slot.rendered = false; } else if (slot.w != w || slot.h != h) { api->UpdateFramebufferParameters(slot.fbId, w, h, 1, true, true, true, true); interp->mFrameBuffers[slot.fbId] = { w, h, w, h, w, h, false, false }; slot.w = w; slot.h = h; slot.rendered = false; // resize discards the old contents } if (newOwner) { slot.rendered = false; } // Content hash catches pose changes (e.g. animation frames). const uint64_t partsHash = HashRequest(req); const bool dirty = !slot.rendered || !SameView(slot.lastView, req.view) || slot.lastPartsHash != partsHash; if (dirty) { dirtySlots.emplace_back(idx, (int)ri); } } // Round-robin so simultaneous animations don't starve each other. for (const auto& [idx, ri] : dirtySlots) { if (ri >= (int)mScanStart) { targetIdx = idx; targetReqIdx = ri; break; } } if (targetIdx < 0 && !dirtySlots.empty()) { targetIdx = dirtySlots.front().first; targetReqIdx = dirtySlots.front().second; } if (targetReqIdx >= 0) { mScanStart = (size_t)targetReqIdx + 1; } if (std::getenv("TORCH_UI_RENDERLOG") != nullptr) { static int sFrame = 0; fprintf(stderr, "[render] f%d reqs=%zu dirty=%zu target=%s\n", sFrame++, mRenderList.size(), dirtySlots.size(), targetReqIdx >= 0 ? mRenderList[targetReqIdx].name.c_str() : "(none)"); } // Mirror the pool's owners so DrawModelParts can blit persisted slots. // Unrendered slots are excluded: a freshly created/resized Metal // texture holds stale VRAM (other previews' old renders) until this // slot's round-robin turn comes; blitting it bleeds display lists. mNameToFb.clear(); for (const auto& s : mFbPool) { if (s.fbId >= 0 && !s.owner.empty() && s.rendered) { mNameToFb[s.owner] = s.fbId; } } if (targetIdx < 0) { return nullptr; // nothing changed; every framebuffer keeps its last render } // Phase 2: render the target model into its framebuffer. const ModelRequest& req = mRenderList[targetReqIdx]; FbSlot& slot = mFbPool[targetIdx]; // Parts that fail to load are dropped rather than failing the model. struct DrawEntry { const ModelPart* first; Gfx* second; bool raw; }; std::vector drawable; drawable.reserve(req.parts.size()); for (const auto& part : req.parts) { Gfx* gfx = nullptr; bool raw = false; auto rawIt = mRawModels.find(part.resource); auto gameIt = mGameGfx.find(part.resource); if (rawIt != mRawModels.end()) { gfx = rawIt->second.gfx.data(); raw = true; } else if (gameIt != mGameGfx.end()) { gfx = gameIt->second.second; } else { auto dl = std::static_pointer_cast(rm->LoadResource(part.resource)); gfx = dl != nullptr ? dl->GetPointer() : nullptr; } if (gfx != nullptr) { drawable.push_back({ &part, gfx, raw }); } } if (drawable.empty()) { slot.rendered = true; // nothing loadable; don't retry every frame slot.lastView = req.view; slot.lastPartsHash = HashRequest(req); return nullptr; } // Master-list order (layer 0..7) so transparency blends over opaque. std::stable_sort(drawable.begin(), drawable.end(), [](const auto& a, const auto& b) { return a.first->layer < b.first->layer; }); auto bit = mBoundsCache.find(req.name); if (bit == mBoundsCache.end()) { bit = mBoundsCache.emplace(req.name, ComputePartsBounds(req.parts, rm)).first; } const ModelBounds b = bit->second; const OrbitView& v = req.view; const float aspect = (float)slot.w / (float)slot.h; const float dist = (b.radius * 2.5f) / std::max(v.zoom, 0.02f); // Camera basis (matches LookAt). Pan shifts eye and target along x/y, // scaled by distance so drag speed is zoom-independent. const float zx = std::cos(v.pitch) * std::sin(v.yaw); const float zy = std::sin(v.pitch); const float zz = std::cos(v.pitch) * std::cos(v.yaw); float xx = zz, xz = -zx; const float xl = std::sqrt(xx * xx + xz * xz); if (xl > 0.0001f) { xx /= xl; xz /= xl; } const float yx = zy * xz; const float yy = zz * xx - zx * xz; const float yz = -zy * xx; const float panScale = dist * 0.0015f; const float cx = b.cx + (-v.panX * xx) * panScale + (v.panY * yx) * panScale; const float cy = b.cy + (v.panY * yy) * panScale; const float cz = b.cz + (-v.panX * xz) * panScale + (v.panY * yz) * panScale; const float eyeX = cx + dist * zx; const float eyeY = cy + dist * zy; const float eyeZ = cz + dist * zz; MtxF projF{}; Perspective(projF, 45.0f, aspect, std::max(dist * 0.08f, 1.0f), dist * 4.0f + b.radius * 4.0f); MtxF viewF{}; LookAt(viewF, eyeX, eyeY, eyeZ, cx, cy, cz); mMtxReplacements[&slot.proj] = projF; // Modelview per part = world * view. Billboards get an identity rotation // in view space at their anchor (mtxf_billboard). mPartMtxKeys provides // stable addresses for the replacement map; no reallocation before Run. mPartMtxKeys.resize(drawable.size()); for (size_t i = 0; i < drawable.size(); ++i) { const ModelPart& part = *drawable[i].first; MtxF mv{}; if (part.billboard) { MtxF bb{}; bb.mf[0][0] = bb.mf[1][1] = bb.mf[2][2] = bb.mf[3][3] = 1.0f; for (int c = 0; c < 3; ++c) { bb.mf[3][c] = part.anchor[0] * viewF.mf[0][c] + part.anchor[1] * viewF.mf[1][c] + part.anchor[2] * viewF.mf[2][c] + viewF.mf[3][c]; } MulMtxF(mv, part.mtx, bb); } else { MulMtxF(mv, part.mtx, viewF); } mMtxReplacements[&mPartMtxKeys[i]] = mv; } const int16_t vx = (int16_t)(slot.w * 2); const int16_t vy = (int16_t)(slot.h * 2); slot.vp.vp.vscale[0] = vx; slot.vp.vp.vscale[1] = vy; slot.vp.vp.vscale[2] = G_MAXZ / 2; slot.vp.vp.vscale[3] = 0; slot.vp.vp.vtrans[0] = vx; slot.vp.vp.vtrans[1] = vy; slot.vp.vp.vtrans[2] = G_MAXZ / 2; slot.vp.vp.vtrans[3] = 0; // Point light approximated per part (the RSP only has directional // lights): direction from the light position to the part, color // attenuated by distance, supplied in view space. Parts that bind their // own material lights override it. const PreviewLighting& lighting = GetPreviewLighting(); const auto to8 = [](float v) { return (uint8_t)std::clamp((int)(v * 255.0f + 0.5f), 0, 255); }; const float lpx = b.cx + lighting.position[0] * b.radius; const float lpy = b.cy + lighting.position[1] * b.radius; const float lpz = b.cz + lighting.position[2] * b.radius; mPartLights.resize(drawable.size()); for (size_t i = 0; i < drawable.size(); ++i) { const ModelPart& part = *drawable[i].first; const float px = part.billboard ? part.anchor[0] : part.mtx[3][0]; const float py = part.billboard ? part.anchor[1] : part.mtx[3][1]; const float pz = part.billboard ? part.anchor[2] : part.mtx[3][2]; float dx = lpx - px, dy = lpy - py, dz = lpz - pz; const float distSq = dx * dx + dy * dy + dz * dz; const float dlen = std::sqrt(distSq); if (dlen > 0.0001f) { dx /= dlen; dy /= dlen; dz /= dlen; } else { dy = 1.0f; dx = dz = 0.0f; } const float radii = b.radius > 0.0001f ? dlen / b.radius : 0.0f; const float atten = std::clamp(lighting.intensity / (1.0f + lighting.falloff * radii * radii), 0.0f, 1.0f); // World direction -> view space (rotation rows of the view matrix). const float vx = dx * viewF.mf[0][0] + dy * viewF.mf[1][0] + dz * viewF.mf[2][0]; const float vy = dx * viewF.mf[0][1] + dy * viewF.mf[1][1] + dz * viewF.mf[2][1]; const float vz = dx * viewF.mf[0][2] + dy * viewF.mf[1][2] + dz * viewF.mf[2][2]; const float amb[3] = { part.fullAmbient ? 1.0f : lighting.ambient[0], part.fullAmbient ? 1.0f : lighting.ambient[1], part.fullAmbient ? 1.0f : lighting.ambient[2] }; mPartLights[i] = gdSPDefLights1(to8(amb[0]), to8(amb[1]), to8(amb[2]), to8(lighting.color[0] * atten), to8(lighting.color[1] * atten), to8(lighting.color[2] * atten), (int8_t)(vx * 127.0f), (int8_t)(vy * 127.0f), (int8_t)(vz * 127.0f)); } const Backdrop* backdrop = nullptr; if (const auto bdit = mBackdrops.find(req.name); bdit != mBackdrops.end() && bdit->second.w > 0) { backdrop = &bdit->second; } size_t backdropCmds = 0; if (backdrop != nullptr) { const int rowsPer = std::max(1, 2048 / backdrop->w); backdropCmds = 32 + (size_t)((backdrop->h + rowsPer - 1) / rowsPer) * 12; } mCmd.assign(80 + drawable.size() * 64 + backdropCmds, Gfx{}); Gfx* p = mCmd.data(); // Load f3d ucode + reset segment 0 (global state). p->words.w0 = ((uintptr_t)0xDD << 24) | ((uintptr_t)ucode_f3d & 0xFFFFFF); p->words.w1 = 0; ++p; __gSPSegment(p++, 0, 0x0); // Non-zero color image address so FILL rects clear color instead of // being treated as depth clears. gDPSetColorImage(p++, G_IM_FMT_RGBA, G_IM_SIZ_16b, 1, (void*)(uintptr_t)0x10); // Switch the render target to this model's framebuffer (G_SETFB 0x21). p->words.w0 = (uintptr_t)0x21 << 24; p->words.w1 = (uintptr_t)slot.fbId; ++p; // Color clear (depth was cleared by G_SETFB). The fill quad sits at the // near plane, so it must not write depth. gDPPipeSync(p++); gDPSetScissor(p++, G_SC_NON_INTERLACE, 0, 0, (int)slot.w, (int)slot.h); gDPSetRenderMode(p++, G_RM_OPA_SURF, G_RM_OPA_SURF2); gDPSetCycleType(p++, G_CYC_FILL); gDPSetFillColor(p++, 0x10851085); // ~(18,18,22) packed RGBA5551 twice gDPFillRectangle(p++, 0, 0, (int)slot.w - 1, (int)slot.h - 1); gDPPipeSync(p++); gSPViewport(p++, &slot.vp); gSPClearGeometryMode(p++, 0xFFFFFFFF); gSPTexture(p++, 0xFFFF, 0xFFFF, 0, G_TX_RENDERTILE, G_OFF); gDPSetTexturePersp(p++, G_TP_PERSP); gDPSetTextureLOD(p++, G_TL_TILE); gDPSetTextureDetail(p++, G_TD_CLAMP); gDPSetTextureLUT(p++, G_TT_NONE); gDPSetTextureFilter(p++, G_TF_BILERP); gDPSetTextureConvert(p++, G_TC_FILT); gDPSetCombineKey(p++, G_CK_NONE); gDPSetAlphaCompare(p++, G_AC_NONE); gDPSetColorDither(p++, G_CD_DISABLE); gSPMatrix(p++, &slot.proj, G_MTX_PROJECTION | G_MTX_LOAD | G_MTX_NOPUSH); gDPSetCombineMode(p++, G_CC_SHADE, G_CC_SHADE); gDPSetDepthSource(p++, G_ZS_PIXEL); gDPSetCycleType(p++, G_CYC_1CYCLE); if (backdrop != nullptr) { // Cover-fit texture-rectangle strips (TMEM holds 2048 RGBA16 // texels per load), drawn before any geometry, no depth writes. gDPPipeSync(p++); gDPSetTexturePersp(p++, G_TP_NONE); gDPSetRenderMode(p++, G_RM_OPA_SURF, G_RM_OPA_SURF2); gDPSetCombineMode(p++, G_CC_DECALRGBA, G_CC_DECALRGBA); gSPTexture(p++, 0xFFFF, 0xFFFF, 0, G_TX_RENDERTILE, G_ON); const float sc = std::max((float)slot.w / backdrop->w, (float)slot.h / backdrop->h); const float offX = ((float)slot.w - backdrop->w * sc) * 0.5f; const float offY = ((float)slot.h - backdrop->h * sc) * 0.5f; const int rowsPer = std::max(1, 2048 / backdrop->w); const int dsdx = (int)(1024.0f / sc); // s5.10 texels per pixel for (int row = 0; row < backdrop->h; row += rowsPer) { const int rows = std::min(rowsPer, backdrop->h - row); gDPLoadTextureBlock(p++, backdrop->texels->data() + (size_t)row * backdrop->w * 2, G_IM_FMT_RGBA, G_IM_SIZ_16b, backdrop->w, rows, 0, G_TX_CLAMP, G_TX_CLAMP, G_TX_NOMASK, G_TX_NOMASK, G_TX_NOLOD, G_TX_NOLOD); const float y0f = offY + row * sc; const int x0 = std::max((int)offX, 0); const int x1 = std::min((int)std::ceil(offX + backdrop->w * sc), (int)slot.w); const int y0 = std::clamp((int)y0f, 0, (int)slot.h); const int y1 = std::clamp((int)std::ceil(offY + (row + rows) * sc), 0, (int)slot.h); if (x1 <= x0 || y1 <= y0) { continue; } const int S = (int)((x0 - offX) / sc * 32.0f); const int T = (int)((y0 - y0f) / sc * 32.0f); gSPTextureRectangle(p++, x0 << 2, y0 << 2, x1 << 2, y1 << 2, G_TX_RENDERTILE, S, T, dsdx, dsdx); } gDPPipeSync(p++); gDPSetTexturePersp(p++, G_TP_PERSP); gSPTexture(p++, 0xFFFF, 0xFFFF, 0, G_TX_RENDERTILE, G_OFF); gDPSetCombineMode(p++, G_CC_SHADE, G_CC_SHADE); } const PreviewAtmosphere& atmo = GetPreviewAtmosphere(); const bool fog = atmo.fogEnabled; uint32_t fogWord = 0; if (fog) { gDPSetFogColor(p++, to8(atmo.fogColor[0]), to8(atmo.fogColor[1]), to8(atmo.fogColor[2]), 255); // G_MOVEWORD/G_MW_FOG, raw-encoded per dialect (the compiled gbi // macros target another one). w1 = fog scale << 16 | fog offset. const int range = std::max(atmo.fogEnd - atmo.fogStart, 1); const uint16_t fm = (uint16_t)(int16_t)(128000 / range); const uint16_t fo = (uint16_t)(int16_t)(((500 - atmo.fogStart) * 256) / range); fogWord = ((uint32_t)fm << 16) | fo; p->words.w0 = ((uintptr_t)0xBC << 24) | 0x08; p->words.w1 = fogWord; ++p; } const uint32_t baseGeo = G_ZBUFFER | G_SHADE | G_SHADING_SMOOTH | (fog ? G_FOG : 0); gSPSetGeometryMode(p++, baseGeo | (lighting.enabled ? G_LIGHTING : 0)); // Per-layer render modes (SM64 renderModeTable_1Cycle, z-buffered). static const uint32_t kLayerCycle1[8] = { G_RM_ZB_OPA_SURF, G_RM_AA_ZB_OPA_SURF, G_RM_AA_ZB_OPA_DECAL, G_RM_AA_ZB_OPA_INTER, G_RM_AA_ZB_TEX_EDGE, G_RM_AA_ZB_XLU_SURF, G_RM_AA_ZB_XLU_DECAL, G_RM_AA_ZB_XLU_INTER, }; static const uint32_t kLayerCycle2[8] = { G_RM_ZB_OPA_SURF2, G_RM_AA_ZB_OPA_SURF2, G_RM_AA_ZB_OPA_DECAL2, G_RM_AA_ZB_OPA_INTER2, G_RM_AA_ZB_TEX_EDGE2, G_RM_AA_ZB_XLU_SURF2, G_RM_AA_ZB_XLU_DECAL2, G_RM_AA_ZB_XLU_INTER2, }; uint32_t lastRm1 = 0, lastRm2 = 0; int lastCyc = -1; for (size_t i = 0; i < drawable.size(); ++i) { const int layer = drawable[i].first->layer & 7; uint32_t rm1, rm2; int cyc; if (drawable[i].first->renderMode1 != 0 || drawable[i].first->renderMode2 != 0) { rm1 = drawable[i].first->renderMode1; rm2 = drawable[i].first->renderMode2; cyc = drawable[i].first->cycleType; } else { rm1 = kLayerCycle1[layer]; rm2 = kLayerCycle2[layer]; cyc = 1; } if (fog) { // Fog runs in blender cycle 1 (the game's RENDER_CLASS_FOG). rm1 = G_RM_FOG_SHADE_A; cyc = 2; } if (rm1 != lastRm1 || rm2 != lastRm2 || cyc != lastCyc) { gDPPipeSync(p++); gDPSetCycleType(p++, cyc == 2 ? G_CYC_2CYCLE : G_CYC_1CYCLE); gDPSetRenderMode(p++, rm1, rm2); lastRm1 = rm1; lastRm2 = rm2; lastCyc = cyc; } gSPSetLights1(p++, mPartLights[i]); gSPMatrix(p++, &mPartMtxKeys[i], G_MTX_MODELVIEW | G_MTX_LOAD | G_MTX_NOPUSH); const ModelPart& partRef = *drawable[i].first; if (partRef.texture != nullptr && partRef.texture->blob != nullptr) { p = EmitPartTexture(p, *partRef.texture, fog); } else if (partRef.gameShade == 3) { // auto: impose no combine/texture state — the DL renders as // authored (it sets its own texture/combine). } else if (partRef.gameShade == 1) { // Game DL supplies its own texture; enable texturing and // modulate it by shade so the model's textures show. gDPPipeSync(p++); gSPTexture(p++, 0xFFFF, 0xFFFF, 0, G_TX_RENDERTILE, G_ON); gDPSetTextureLUT(p++, G_TT_NONE); gDPSetTextureFilter(p++, G_TF_BILERP); if (fog) { gDPSetCombineMode(p++, G_CC_MODULATERGB, G_CC_PASS2); } else { gDPSetCombineMode(p++, G_CC_MODULATERGB, G_CC_MODULATERGB); } } else if (partRef.gameShade == 2) { gDPPipeSync(p++); gSPTexture(p++, 0xFFFF, 0xFFFF, 0, G_TX_RENDERTILE, G_OFF); gDPSetTextureLUT(p++, G_TT_NONE); gDPSetPrimColor(p++, 0, 0, 200, 200, 210, 255); if (fog) { gDPSetCombineMode(p++, G_CC_PRIMITIVE, G_CC_PASS2); } else { gDPSetCombineMode(p++, G_CC_PRIMITIVE, G_CC_PRIMITIVE); } } else { gDPPipeSync(p++); gSPTexture(p++, 0xFFFF, 0xFFFF, 0, G_TX_RENDERTILE, G_OFF); gDPSetTextureLUT(p++, G_TT_NONE); // Shade-only: MODULATE variants still sample TEXEL0, which // would multiply in whatever texture the previous part bound. if (fog) { gDPSetCombineMode(p++, G_CC_SHADE, G_CC_PASS2); } else { gDPSetCombineMode(p++, G_CC_SHADE, G_CC_SHADE); } } const UcodeHandlers gameUcode = ConfigUcode(); if (!drawable[i].raw && gameUcode != ucode_f3d) { // Run the game's list under its own dialect, then return to // F3D for the prefix commands. G_DL is 0xDE on f3dex2. p->words.w0 = ((uintptr_t)0xDD << 24) | ((uintptr_t)gameUcode & 0xFFFFFF); p->words.w1 = 0; ++p; if (fog) { // Ucode loads reset the RSP fog factor; re-arm it in the // game dialect (f3dex2 moveword: index in w0 bits 16-23). if (gameUcode == ucode_f3dex2) { p->words.w0 = ((uintptr_t)0xDB << 24) | ((uintptr_t)0x08 << 16); } else { p->words.w0 = ((uintptr_t)0xBC << 24) | 0x08; } p->words.w1 = fogWord; ++p; } const bool partLit = lighting.enabled && !partRef.unlit; if (gameUcode == ucode_f3dex2) { // Geometry-mode bits are stored raw and read per-dialect: // F3D's SMOOTH (0x200) is f3dex2's CULL_FRONT. Re-set the // baseline with f3dex2 values (SMOOTH = 0x200000). p->words.w0 = (uintptr_t)0xD9 << 24; // clear all p->words.w1 = 0x1 | 0x4 | 0x200000 | (fog ? 0x10000 : 0) | (partLit ? 0x20000 : 0); ++p; } else { // f3dex/f3dexb share f3d's geometry bit layout. Re-set per // part so unlit (vertex-colored) limbs skip lighting. p->words.w0 = (uintptr_t)0xB6 << 24; // G_CLEARGEOMETRYMODE (all) p->words.w1 = 0xFFFFFFFF; ++p; p->words.w0 = (uintptr_t)0xB7 << 24; // G_SETGEOMETRYMODE p->words.w1 = 0x1 | 0x4 | 0x200 | 0x2000 | (partLit ? 0x20000 : 0); ++p; } p->words.w0 = (uintptr_t)(gameUcode == ucode_f3dex2 ? 0xDE : 0x06) << 24; p->words.w1 = (uintptr_t)drawable[i].second; ++p; p->words.w0 = ((uintptr_t)0xDD << 24) | ((uintptr_t)ucode_f3d & 0xFFFFFF); p->words.w1 = 0; ++p; if (fog) { p->words.w0 = ((uintptr_t)0xBC << 24) | 0x08; p->words.w1 = fogWord; ++p; } if (gameUcode == ucode_f3dex2) { gSPClearGeometryMode(p++, 0xFFFFFFFF); gSPSetGeometryMode(p++, baseGeo | (lighting.enabled ? G_LIGHTING : 0)); } } else { __gSPDisplayList(p++, drawable[i].second); } } gDPFullSync(p++); // Restore the main framebuffer (G_RESETFB 0x22) before the gui draws. p->words.w0 = (uintptr_t)0x22 << 24; p->words.w1 = 0; ++p; gSPEndDisplayList(p++); if ((size_t)(p - mCmd.data()) > mCmd.size()) { SPDLOG_ERROR("Preview command list overflow: {} > {}", (size_t)(p - mCmd.data()), mCmd.size()); } if (const char* dump = std::getenv("TORCH_UI_DUMPCMDS"); dump != nullptr && req.name.find(dump) != std::string::npos) { static int sRenderCount = 0; size_t texCount = 0; for (const auto& d : drawable) { texCount += d.first->texture != nullptr ? 1 : 0; } fprintf(stderr, "[cmds] render %d of %s: %zu cmds, %zu/%zu parts textured in request\n", sRenderCount++, req.name.c_str(), (size_t)(p - mCmd.data()), texCount, drawable.size()); for (Gfx* g = mCmd.data(); g < p; ++g) { fprintf(stderr, "[cmds] %02X %08X %016llX\n", (unsigned)(g->words.w0 >> 24) & 0xFF, (unsigned)(g->words.w0 & 0xFFFFFF), (unsigned long long)g->words.w1); } } slot.rendered = true; slot.lastView = req.view; slot.lastPartsHash = HashRequest(req); return mCmd.data(); } // The game's display lists use the configured GBI dialect; our generated // preview meshes are always F3D. static UcodeHandlers ConfigUcode() { if (Companion::Instance == nullptr) { return ucode_f3d; } switch (Companion::Instance->GetGBIVersion()) { case GBIVersion::f3db: return ucode_f3db; case GBIVersion::f3dex: return ucode_f3dex; case GBIVersion::f3dexb: return ucode_f3dexb; case GBIVersion::f3dex2: return ucode_f3dex2; default: return ucode_f3d; } } static constexpr int kFbPoolSize = 24; // A model preview render request, queued by DrawModelParts during the gui draw. struct ModelRequest { std::string name; std::vector parts; ImVec2 topLeft; ImVec2 size; OrbitView view; }; // Parts hash plus the backdrop generation, so backdrop edits re-render. uint64_t HashRequest(const ModelRequest& req) { uint64_t h = HashParts(req.parts); const auto it = mBackdrops.find(req.name); if (it != mBackdrops.end()) { h = h * 1099511628211ull + it->second.generation; } return h; } std::vector mRequests; // filled this frame, rendered next std::vector mRenderList; // snapshot being rendered this frame std::unordered_map mNameToFb; // model -> framebuffer id std::unordered_map mBoundsCache; std::vector mFbPool; size_t mScanStart = 0; // round-robin cursor for the one-render-per-frame pick // Stable keys for the Mtx->MtxF replacement map. std::vector mPartMtxKeys; // Per-part light evaluation; alive through the interpreter Run. std::vector mPartLights; std::unordered_map mRawModels; // Game-dialect display lists registered from parsed blobs. Pointer // operands are resolved once into owned native Gfx arrays. struct GameBundle { std::shared_ptr> blob; uint32_t vtxBase = 0; uint32_t vtxSize = 0; std::unordered_map> dlists; // Vertex spans (blob offset, count) and G_DL children per dlist, for // per-dlist bounds. struct DlMeta { std::vector> vtxSpans; std::vector children; }; std::unordered_map meta; }; std::unordered_map> mGameBundles; // keyed by blob std::unordered_map, Gfx*>> mGameGfx; // Screen-space backdrops (N64 RGBA16 texels) drawn as texture-rectangle // strips before a model's parts. struct Backdrop { const std::vector* texels = nullptr; int w = 0, h = 0; uint64_t generation = 0; }; std::unordered_map mBackdrops; // Content-hash -> converted texels; entries outlive backdrop switches. std::unordered_map> mBackdropTexelCache; // One-shot sample playback, pushed to the LUS audio player each frame with // nearest-neighbor resampling to the device rate. std::vector mAudioPcm; size_t mAudioTotalFrames = 0; double mAudioPos = 0.0; int mAudioSrcRate = 0; int mAudioSrcChannels = 1; float mAudioVolume = 1.0f; float mAudioSpeed = 1.0f; std::vector mCmd; std::unordered_map mMtxReplacements; }; } // namespace std::unique_ptr CreateLusBackend() { return std::make_unique(); } } // namespace UI #endif // BUILD_UI