diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 36657b6..7539f2c 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -1,7 +1,7 @@ # Mirrors VELD-Dev/Riftripper's nightly workflow: every push to `nightly` builds Windows + Linux # artifacts and publishes/updates them under a single rolling "Nightly Builds" GitHub release # (tag `nightly`), accumulating every nightly build's artifacts there rather than replacing them -# (replacesArtifacts: false below) — the running app's UpdateChecker (Nightly channel) picks the +# (replacesArtifacts: false below) - the running app's UpdateChecker (Nightly channel) picks the # newest artifact per platform out of that list by filename. name: Nightly Releases @@ -14,14 +14,14 @@ jobs: build: name: Nightly Builds - # ncipollo/release-action needs write access to create/update the `nightly` release — without + # ncipollo/release-action needs write access to create/update the `nightly` release - without # this, it inherits the default GITHUB_TOKEN, which many repos/orgs now default to read-only, # and the create/update call fails with "403: Resource not accessible by integration". permissions: contents: write strategy: - # max-parallel: 1 serializes Windows/Linux — both jobs publish to the same rolling `nightly` + # max-parallel: 1 serializes Windows/Linux - both jobs publish to the same rolling `nightly` # release tag via ncipollo/release-action, and on the very first run (before that release # exists) two parallel "create" calls race and one gets a 422. Serial runs cost wall time but # this workflow only fires on nightly pushes, so it's not latency-sensitive. @@ -59,7 +59,7 @@ jobs: echo "commit_hash=$commit_hash" >> "$GITHUB_ENV" echo "build_date=$build_date" >> "$GITHUB_ENV" - # NightlyBuildInfo.cs is checked in with both consts null (every other build) — this run + # NightlyBuildInfo.cs is checked in with both consts null (every other build) - this run # is the one place they get filled in, matching what UpdateChecker.CheckNightly extracts # back out of the artifact filename below to compare against. cat > ReLunacy/NightlyBuildInfo.cs < ~110 ms in `Draw Record` alone, even after pipeline-cache + instancing + sort fixes. +- This is an **editor**, not a game: free-fly camera (no gameplay draw-distance guarantee), gizmo editing, per-entity selection/picking. + +A senior take: on PS3 this was cheap because libgcm draw calls were nearly free and the game pre-built display lists with LODs + portal culling. We don't have cheap calls (managed Vulkan wrapper), so the equivalent move is **build the command stream once and replay it** - stop paying the per-call tax every frame. + +## Core principle: retained command stream, recorded once, replayed per frame + +The scene's draw commands are identical frame to frame. So: + +1. Build a **retained render list** from the entities once (on level load / scene change). +2. **Record it into a dedicated CommandList once** (Begin...End), and keep that recorded list. +3. Each frame: update the small per-frame uniforms (view/projection, light), **submit the pre-recorded list**, present. The 40k calls happen **once**, not 60x/second. +4. Per-frame CPU cost collapses to: a couple of uniform updates + one `SubmitCommands` + present. + +Everything that legitimately changes per frame is tiny and goes in a **separate per-frame CommandList**: selection outline, gizmo, axis widget, bounding spheres, overlays. That's a handful of draws. + +### Why this works with an editor + +- **Camera**: view/projection live in a uniform buffer the shaders already read. Moving the camera updates that buffer - **no re-record**. +- **Editing a transform** (gizmo drag): transforms live in the storage buffer (already implemented); the recorded list reads them by `gl_InstanceIndex`. Updating one SSBO slot changes the object's position **without re-recording**. +- **Selection**: highlight is a separate per-frame pass over the one selected entity - never touches the retained list. + +### When we DO re-record (set a `dirty` flag, rebuild+re-record next frame) + +- Level load / unload. +- Show/hide toggles (mobys/ties/ufrags/foliage/volumes), lighting on/off (changes the effect), MSAA/resize (new `OutputDescription` / pipelines). +- Adding/removing an instance. (Moving one does **not** - SSBO update only.) + +## Culling decision + +Drop **per-frame** frustum culling for the retained list - record *all* opaque/translucent geometry once and let the idle GPU draw it. GPU headroom is real (that's why GPU usage is low). If a level ever turns GPU-bound, add **coarse, infrequent culling** that only re-records when the visible set materially changes (e.g. crossing zone boundaries), never every frame. This trades a little GPU for ~all of the per-frame CPU. + +## Data model + +``` +RenderItem // one draw + pipeline // resolved SimplePipeline (from our reference-keyed cache) + materialSets // MaterialBuffer + the textures that differ (deduped at build) + mesh // vertex/index buffers + firstInstance,count // range into the transform SSBO (instanced draw) + +RenderPassList // opaque list + translucent list, each: RenderItem[] sorted by + // (pipeline, material, mesh) so recording binds each state once + +TransformStore // one StructuredBufferReadOnly of all instance world matrices, + // indexed by gl_InstanceIndex; updated in place on edit +MaterialStore // (future) material params in one buffer indexed per-draw, to kill + // the per-material MaterialBuffer bind +``` + +The retained list is built by walking `EntityManager` once, exactly like `DecalAwareForwardRenderer.Draw` does today (sort -> batch by mat+mesh -> instanced ranges), but the *output is data* (RenderItems), not immediate command-list calls. + +## Passes (frame) - AS BUILT + +One command buffer, re-recorded each frame with only the visible draws, one submit. Three render passes over a shared depth-stencil buffer: + +1. **Opaque** -> colour + depth. Depth-writing work first (Opaque/Cutout, Soft-Edge's alpha-tested depth prepass, foliage billboards), then Additive (depth-tested, no write), then the editor's volume wireframes. +2. **Accumulate** -> RGBA16F accum + R16F reveal. Overlay/Scunge/Blended and Soft-Edge's colour pass, blended commutatively (weighted-blended OIT), depth-tested but not writing. No sorting, no re-record. +3. **Resolve** -> a fullscreen triangle composites accum/reveal over the opaque colour, then the selection outline draws on top of the finished image (stencil mask-and-inflate). + +Every non-opaque draw carries the game's polygon offset (`depthBias -87`, `slopeScaled -0.33972`, from a RenderDoc capture). ImGui gizmos still render over the resulting texture on the ImGui side. + +## Reuse, don't rewrite-from-scratch + +The hard-won correct pieces stay and become the *builder* for the retained list: +- Instancing via `InstanceTransforms` SSBO + `gl_InstanceIndex` (works; `AssetManager.BuildLitModelEffect` declares it as `StructuredReadOnly`). +- Reference-keyed pipeline cache. +- (material, mesh) coherence sort with the packed primitive key. +- Per-slot texture-bind dedup. +- The lit-effect material/texture layout, lighting, cubemap, baked-light plumbing. + +The genuinely *new* part is small and surgical: (a) emit RenderItems instead of calling the CommandList directly; (b) own a retained CommandList recorded on `dirty`; (c) replay it each frame; (d) move selection/gizmo into a per-frame list; (e) the `dirty` triggers. + +## Migration stages (each independently testable) + +1. **Retained record + replay** - record the current scene draws into a renderer-owned CommandList once, replay each frame; re-record on a coarse `dirty` flag (any level/visibility change). Verify visuals identical; watch `Draw Record`/frame time collapse. *(Biggest win; do first.)* +2. **Move camera/light updates out of the recorded list** into per-frame uniform writes, so camera motion needs no re-record. +3. **SSBO-update-on-edit** so gizmo drags don't re-record. +4. **Split selection/gizmo/overlay** into a separate per-frame list. +5. **(Optional) MaterialStore** - collapse the per-material `MaterialBuffer` bind into one indexed buffer, cutting the remaining `Binds` cost. +6. **(Optional) submesh/mesh-buffer merge** at load - fewer meshes => fewer draws even in the retained list. + +Stages 1-4 remove the per-frame floor entirely for a static scene. 5-6 shrink the one-time record cost (matters on re-record). + +## RESOLVED: Veldrith blocks record-once (measured from the DLL) + +`Veldrith.Vk.VkCommandList.Begin()` acquires a fresh/recycled command buffer (`GetNextCommandBuffer()`) and calls `vkBeginCommandBuffer` with `VkCommandBufferUsageFlags = 1` = **ONE_TIME_SUBMIT_BIT**. `SIMULTANEOUS_USE_BIT` (0x4) is never set anywhere. So a recorded Veldrith CommandList is, by contract, submit-once-then-re-record. Vulkan validation forbids resubmitting it across frames. **Record-once/replay is impossible through Veldrith's CommandList.** + +Veldrith is built on **Vortice.Vulkan** (raw bindings), which is therefore already a transitive dependency. Two ways to get record-once: +- **Hybrid**: keep Veldrith for device/resources (buffers, textures, pipelines, descriptor sets, swapchain, ImGui) but record the SCENE into our own `SIMULTANEOUS_USE` VkCommandBuffer via raw Vortice.Vulkan, submitted each frame. Needs Veldrith to expose the raw `VkPipeline`/`VkDescriptorSet`/`VkBuffer` handles (unverified) and render-pass compatibility. +- **Full raw-Vulkan renderer**: own the whole pipeline on Vortice.Vulkan / Silk.NET.Vulkan. Maximum control (record-once, secondary buffers, bindless, custom allocator) and the cleanest home for animations/particles/splines - but a multi-session, hardware-tested build that also re-ports the material/lighting/effect system (the game-faithful shader port). + +## Two independent levers (decide per goal) + +1. **Fewer command-list calls** - backend-agnostic, reuses everything, low risk. Merge mesh buffers (kill per-mesh vertex/index binds), consolidate/atlas materials, merge same-material submeshes at load. Realistic target ~5-8k calls => ~15-20 ms even on Veldrith's per-call tax. **Fastest route out of "critical".** +2. **Eliminate per-frame recording** (record-once) - the structural end-state, but needs raw Vulkan per above. The right long-term answer, especially with animations (bone SSBO), particles/splines (per-frame dynamic pass) incoming. + +## LOCKED DECISION (2026-08-06) + +**Build a from-scratch raw-Vulkan renderer, sharing Veldrith's device during the staged migration.** + +- **Binding: Vortice.Vulkan 3.2.3** - forced: it's the exact version Veldrith uses, so the handles it hands back are Vortice types. Added as a direct dependency of ReLunacy.Engine. +- **Device sharing:** `GraphicsDevice.GetVulkanInfo()` -> `Veldrith.BackendInfoVulkan` exposes `Instance / Device / PhysicalDevice / GraphicsQueue` as raw `nint` + `GraphicsQueueFamilyIndex` (uint). The new renderer wraps these into Vortice `Vk*` handles - NO second device/instance. The window, swapchain, ImGui, present, and resource creation stay on Veldrith throughout the migration; only the SCENE 3D pass moves to raw Vulkan. +- **Record-once:** our own command pool + command buffers recorded with `VK_COMMAND_BUFFER_USAGE_SIMULTANEOUS_USE_BIT`, recorded on scene-change and re-submitted every frame to the shared queue (fenced). This is the whole point - the ONE_TIME_SUBMIT limit was Veldrith's, not Vulkan's. +- **Render target:** the scene renders into a `VkImage` (its own, or Veldrith's `RenderTexture2D` image via the exposed handle) that ImGui already displays - so View3D's `ImGui.Image(...)` path is unchanged. + +### Stage roadmap (each = a build-and-run milestone you verify on hardware) + +0. **Acquire** - `VulkanContext` wraps the shared Instance/PhysicalDevice/Device/Queue from `GetVulkanInfo`, creates a command pool + fences. Dormant; app unchanged. *(compiles / no-op)* +1. **Clear** - render a solid colour into a `VkImage` via a raw `SIMULTANEOUS_USE` command buffer, recorded once, submitted each frame; display it (prove device-share + record-once end to end). +2. **One triangle / one mesh** - a pipeline (SPIR-V from our existing GLSL), a vertex/index buffer, a descriptor set (camera UBO), one draw. Prove pipelines + descriptors + the shared render pass. +3. **Scene build** - walk EntityManager once into a retained RenderItem list (reuse the sort/batch/instance logic), record it once, replay. This is where `Draw Record` per-frame goes to ~0. +4. **Materials/textures/lighting** - port the lit effect's descriptor layout (transforms SSBO, material buffer, the 7 textures, light UBO, cubemap). Reuse the GLSL + the AssetManager texture/material data (share Veldrith `VkImage`/`VkBuffer` handles rather than re-uploading). +5. **Dynamic pass** - per-frame command buffer for selection/gizmo/overlay; camera/light UBO writes (no re-record on camera move); SSBO transform writes on gizmo edit (no re-record). +6. **Invalidation + culling** - `dirty` re-record triggers; optional coarse culling. Then animations (bone SSBO), particles/splines (dynamic pass). + +Swap the new scene pass in behind a flag; keep DecalAwareForwardRenderer working until stage 4 is verified, then retire it. + +## Dynamic features fit the retained/dynamic split cleanly + +- **Skeletal animation**: bone matrices in a per-object SSBO region, updated per frame; the recorded draw reads them by index - no re-record, just a buffer write. +- **Particles**: dynamic geometry => a small **per-frame** dynamic pass (instanced quads), never in the retained list. +- **Splines**: static level splines => retained; editor path-editing => per-frame debug pass. +- **Selection/gizmo/overlay**: always the per-frame pass. diff --git a/README.md b/README.md index eb1f45c..667fcb5 100644 --- a/README.md +++ b/README.md @@ -34,19 +34,19 @@

✅ Compatibility

-| Game | Engine Version | Lighting | Textures | Shaders/Materials | Mobys | Ties | UFrags | Shrubs | Foliages | Particles | Volumes & Triggers | -|---------------------------------------|:--------------:|:--------:|:--------:|:-----------------:|:-----:|:----:|:------:|:------:|:--------:|:---------:|:------------------:| -| Resistance: Fall Of Man | Old | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ | ✅ | -| Ratchet & Clank: Tools of Destruction | Old | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ | ✅ | -| Ratchet & Clank: Quest for Booty | Old | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ | ✅ | -| Resistance 2 | New | ⚠³ | ✅¹ | 🚧 | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ | ✅ | -| Ratchet & Clank: A Crack In Time | New | ⚠³ | ✅¹ | 🚧 | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ | ✅ | -| Resistance: Retribution | New | ⚠³ | ✅¹ | 🚧 | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ | ✅ | -| Ratchet & Clank: All 4 One | New+ | ⚠³ | ✅² | 🚧 | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ | ✅ | -| Resistance 3 | New+ | ⚠³ | ✅² | 🚧 | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ | ✅ | -| Ratchet & Clank: Full Frontal Assault | New+ | ⚠³ | ✅² | 🚧 | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ | ✅ | -| Resistance: Burning Skies | New+ | ⚠³ | ✅² | 🚧 | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ | ✅ | -| Ratchet & Clank: Into the Nexus | New+ | ⚠³ | ✅² | 🚧 | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ | ✅ | +| Game | Engine Version | Lighting | Textures | Shaders/Materials | Mobys | Ties | UFrags | Shrubs | Foliages | Particles | Volumes & Triggers | +|---------------------------------------|:--------------:|:--------:|:--------:|:-----------------:|:-----:|:----:|:------:|:-------:|:---------:|:-----------:|:------------------:| +| Resistance: Fall Of Man | Old+ | 🚧 | 🚧 | 🚧 | 🚧 | 🚧 | 🚧 | ❌ | ❌ | ❌ | 🚧 | +| Ratchet & Clank: Tools of Destruction | Old | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ | ✅ | +| Ratchet & Clank: Quest for Booty | Old | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ | ✅ | +| Resistance 2 | New | ⚠³ | ✅¹ | 🚧 | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ | ✅ | +| Ratchet & Clank: A Crack In Time | New | ⚠³ | ✅¹ | 🚧 | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ | ✅ | +| Resistance: Retribution | New | ⚠³ | ✅¹ | 🚧 | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ | ✅ | +| Ratchet & Clank: All 4 One | New+ | ⚠³ | ✅² | 🚧 | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ | ✅ | +| Resistance 3 | New+ | ⚠³ | ✅² | 🚧 | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ | ✅ | +| Ratchet & Clank: Full Frontal Assault | New+ | ⚠³ | ✅² | 🚧 | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ | ✅ | +| Resistance: Burning Skies | New+Vita | ? | ? | ? | ? | ? | ? | ❌ | ❌ | ❌ | ? | +| Ratchet & Clank: Into the Nexus | New+ | ⚠³ | ✅² | 🚧 | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ | ✅ | Notes: - **¹** : Textures are supported but some artifacts remain. diff --git a/ReLunacy.Engine/Assets/Cubemaps/Cubemap.cs b/ReLunacy.Engine/Assets/Cubemaps/Cubemap.cs new file mode 100644 index 0000000..dd6a288 --- /dev/null +++ b/ReLunacy.Engine/Assets/Cubemaps/Cubemap.cs @@ -0,0 +1,36 @@ +using ReLunacy.Engine.Assets.Interfaces; + +namespace ReLunacy.Engine.Assets.Cubemaps; + +/// The level's environment cubemap (old-engine section 0x5920). Six square faces the game +/// samples for reflections/ambient; on metropolis it is a near-grey HDR probe whose brightness +/// lives in the alpha channel (a shared exponent, colour ~= rgb * exp2(a*scale+bias)), which is why +/// a plain RGB preview reads as almost white and the alpha channel is where the scene is visible. +/// +/// The faces are decoded (Morton-unswizzled, mip0 only) by Loading.Readers.CubemapReader; see that +/// reader for the on-disk layout. Face order is the GL/RSX convention, matching +/// . +public sealed class Cubemap : IAsset +{ + public ulong Id { get; init; } + public string? Name { get; init; } + public bool IsLoaded => true; + + /// Edge length of one face in texels (32 on metropolis). + public int FaceSize { get; } + + /// The six faces, mip0, in the order given by - each an + /// A8R8G8B8 so the existing decode/preview path handles them unchanged. + public IReadOnlyList Faces { get; } + + /// GL/RSX cube face order, parallel to . + public static readonly string[] FaceNames = ["+X", "-X", "+Y", "-Y", "+Z", "-Z"]; + + public Cubemap(ulong id, int faceSize, IReadOnlyList faces, string? name = null) + { + Id = id; + FaceSize = faceSize; + Faces = faces; + Name = name ?? $"Cubemap_{id:X}"; + } +} diff --git a/ReLunacy.Engine/Assets/Foliage/Foliage.cs b/ReLunacy.Engine/Assets/Foliage/Foliage.cs new file mode 100644 index 0000000..83fdd07 --- /dev/null +++ b/ReLunacy.Engine/Assets/Foliage/Foliage.cs @@ -0,0 +1,71 @@ +using System.Numerics; +using ReLunacy.Engine.Assets.Interfaces; +using ReLunacy.Engine.Loading.Objects; + +namespace ReLunacy.Engine.Assets.Foliage; + +/// One sprite card of a foliage asset: a quad that faces the camera at runtime. +/// +/// is the card's position in the asset's local space and +/// are 2D offsets in the card's own plane - the game builds the +/// final vertex as transform(anchor) + offset * scale, so the offsets are what gives the +/// card its size and the anchor is what places it. address one QUADRANT of +/// the foliage atlas (components are always multiples of 0.5), already V-corrected. +/// +/// is the two undecoded bytes that drive the game's per-sprite rotation +/// through an indexed vertex-constant lookup - carried through so a future billboard shader can +/// use them, meaningless until those constants are recovered. See +/// Loading.Vertices.FoliageSpriteAnchor. +public readonly record struct FoliageSpriteCard( + Vector3 Anchor, + Vector2[] CornerOffsets, + Vector2[] Uvs, + (byte, byte) Packed, + int Lod); + +/// One placement of a foliage asset, straight from the file's affine matrix. +public readonly record struct FoliagePlacement(Matrix4x4 Transform, Vector4 BoundingSphere); + +/// A foliage asset: a set of camera-facing sprite cards (plus branch geometry that is not +/// decoded yet), instanced across the level. See Loading.Objects.FoliageMetadata. +public sealed class Foliage : IAsset +{ + public ulong Id { get; init; } + public string? Name { get; init; } + public bool IsLoaded => true; + + /// The parsed 0xA200 record, for inspectors and for the fields this class doesn't + /// surface (branch LODs, sprite LOD distances, the unidentified flags). + public FoliageMetadata Metadata { get; } + + /// Every sprite card across every LOD. Filter on + /// to draw one level; drawing all of them at once overlaps the LOD chain on top of itself. + public IReadOnlyList Sprites { get; } + + public IReadOnlyList Placements { get; private set; } + + /// The material this foliage draws with, resolved from 's + /// TextureIndex through the old-engine 0x5200 table (see + /// Loading.Objects.FoliageMetadata.TextureIndex and MaterialReader.GetFoliageMaterial — the + /// game indexes that table directly, it is not a shader lookup). Null when the index is the + /// 0xFFFFFFFF sentinel, out of range, or the level was read without a MaterialReader (e.g. new + /// engine); the renderer then falls back to the default billboard texture. The resolved atlas + /// itself is reachable as Material.AlbedoTexture. + public IMaterial? Material { get; } + + public Foliage(ulong id, FoliageMetadata metadata, IReadOnlyList sprites, + IReadOnlyList placements, IMaterial? material = null, string? name = null) + { + Id = id; + Metadata = metadata; + Sprites = sprites; + Placements = placements; + Material = material; + Name = name ?? $"Foliage_{metadata.FoliageId}"; + } + + internal void SetPlacements(IReadOnlyList placements) => Placements = placements; + + /// Cards belonging to one sprite LOD, highest detail first (LOD 0 is the largest set). + public IEnumerable SpritesForLod(int lod) => Sprites.Where(s => s.Lod == lod); +} diff --git a/ReLunacy.Engine/Assets/Geometry/GeometryData.cs b/ReLunacy.Engine/Assets/Geometry/GeometryData.cs index 1b84fa8..627014b 100644 --- a/ReLunacy.Engine/Assets/Geometry/GeometryData.cs +++ b/ReLunacy.Engine/Assets/Geometry/GeometryData.cs @@ -10,6 +10,7 @@ public sealed class GeometryData : IGeometry private readonly float[] _uvs; private readonly float[]? _normals; private readonly float[] _tangents; + private readonly float[]? _lightmapUVs; private readonly float[]? _vertexAlphaCandidates; private readonly uint[] _indices; private readonly int[]? _jointIndices; @@ -21,7 +22,8 @@ public sealed class GeometryData : IGeometry public bool IsLoaded => true; public GeometryData(ulong id, float[] positions, float[] uvs, uint[] indices, float[]? normals = null, BoundingSphere? boundingSphere = null, - int[]? jointIndices = null, float[]? jointWeights = null, float[]? vertexAlphaCandidates = null, float[]? tangents = null) + int[]? jointIndices = null, float[]? jointWeights = null, float[]? vertexAlphaCandidates = null, float[]? tangents = null, + float[]? lightmapUVs = null) { if (positions.Length % 3 != 0) throw new ArgumentException("Positions must be in groups of 3 (x,y,z)", nameof(positions)); @@ -40,6 +42,8 @@ public GeometryData(ulong id, float[] positions, float[] uvs, uint[] indices, fl throw new ArgumentException("Normal count must match vertex count"); if (tangents != null && tangents.Length / 3 != vertexCount) throw new ArgumentException("Tangent count must match vertex count"); + if (lightmapUVs != null && lightmapUVs.Length != vertexCount * 2) + throw new ArgumentException("Lightmap UV count must match vertex count", nameof(lightmapUVs)); if (jointIndices != null && jointIndices.Length != vertexCount * 4) throw new ArgumentException("Joint index count must be vertex count * 4", nameof(jointIndices)); if (jointWeights != null && jointWeights.Length != vertexCount * 4) @@ -49,16 +53,17 @@ public GeometryData(ulong id, float[] positions, float[] uvs, uint[] indices, fl _positions = positions; _uvs = uvs; // Moby/Tie readers now decode real per-vertex normals (VertexFormat0/1's packed signed - // 11:11:10 normal word — see PackedNormal) and pass them in. This fallback only fires for + // 11:11:10 normal word - see PackedNormal) and pass them in. This fallback only fires for // formats that don't carry real normals at all (UFrags currently don't plumb theirs - // through either) — computed from the triangle data itself rather than guessed, so it's + // through either) - computed from the triangle data itself rather than guessed, so it's // still a reasonable substitute where no real data is available. _normals = normals ?? GeometryMath.ComputeNormals(positions, indices); // Same idea for tangents: readers pass in the packed tangent word's decode (real // tangent-space data) when they have it, and GeometryMath falls back to deriving one from // UV gradients (and always derives the handedness sign, since the source format never - // carries one either way — see GeometryMath.ComputeTangents). + // carries one either way - see GeometryMath.ComputeTangents). _tangents = GeometryMath.ComputeTangents(positions, uvs, _normals, indices, tangents); + _lightmapUVs = lightmapUVs; _vertexAlphaCandidates = vertexAlphaCandidates; _indices = indices; _jointIndices = jointIndices; @@ -70,6 +75,7 @@ public GeometryData(ulong id, float[] positions, float[] uvs, uint[] indices, fl public float[] GetTextureCoordinates() => _uvs; public float[]? GetNormals() => _normals; public float[]? GetTangents() => _tangents; + public float[]? GetLightmapUVs() => _lightmapUVs; public float[]? GetVertexAlphaCandidates() => _vertexAlphaCandidates; public uint[] GetIndices() => _indices; public int[]? GetJointIndices() => _jointIndices; diff --git a/ReLunacy.Engine/Assets/Geometry/GeometryMath.cs b/ReLunacy.Engine/Assets/Geometry/GeometryMath.cs index 28c8712..061fe4a 100644 --- a/ReLunacy.Engine/Assets/Geometry/GeometryMath.cs +++ b/ReLunacy.Engine/Assets/Geometry/GeometryMath.cs @@ -11,7 +11,7 @@ internal static class GeometryMath // Standard area-weighted vertex normal generation: accumulate each triangle's (unnormalized, // so larger triangles contribute more) face normal onto its three vertices, then normalize. // Triangle winding (and therefore which way "outward" ends up pointing) isn't independently - // confirmed against these files — if a Decal offset ends up pushing into the surface instead + // confirmed against these files - if a Decal offset ends up pushing into the surface instead // of away from it, that's the first thing to flip (negate the result here), not the offset // magnitude in EditorSettings. public static float[] ComputeNormals(float[] positions, uint[] indices) @@ -45,13 +45,13 @@ public static float[] ComputeNormals(float[] positions, uint[] indices) /// Builds a per-vertex glTF-style tangent (Vector4: xyz direction, w = the +-1 /// bitangent handedness sign), 4 floats per vertex. Uses `realTangents` (3 floats/vertex, - /// decoded straight from VertexFormat0/1's packed tangent word — see PackedNormal) when + /// decoded straight from VertexFormat0/1's packed tangent word - see PackedNormal) when /// supplied, since that's the game's actual tangent-space basis rather than an approximation; /// falls back to the standard UV-gradient method (Lengyel) when the source format doesn't /// carry tangent data at all (currently only UFrag terrain). Either way `w` is derived here - /// from UV winding relative to the (real or derived) tangent — the source files don't carry a + /// from UV winding relative to the (real or derived) tangent - the source files don't carry a /// stored bitangent/handedness bit at all (confirmed: the raw normal/tangent words are - /// fully-consumed pure 11:11:10 direction data, zero spare bits — see PackedNormal), so this + /// fully-consumed pure 11:11:10 direction data, zero spare bits - see PackedNormal), so this /// isn't a shortcut taken only in the fallback case, it's the only way to get `w` regardless /// of where the tangent itself came from. public static float[] ComputeTangents(float[] positions, float[] uvs, float[] normals, uint[] indices, float[]? realTangents) @@ -78,13 +78,13 @@ public static float[] ComputeTangents(float[] positions, float[] uvs, float[] no float det = duv1.X * duv2.Y - duv2.X * duv1.Y; if (MathF.Abs(det) < 1e-12f) - continue; // degenerate UV triangle (zero UV area) — no usable tangent/handedness info + continue; // degenerate UV triangle (zero UV area) - no usable tangent/handedness info float r = 1f / det; var bitangent = (duv1.X * edge2 - duv2.X * edge1) * r; bitangentAccum[i0] += bitangent; bitangentAccum[i1] += bitangent; bitangentAccum[i2] += bitangent; - // Accumulated even when hasReal, purely for TangentDecodeSanityCheck below — the real + // Accumulated even when hasReal, purely for TangentDecodeSanityCheck below - the real // per-vertex path never reads tangentAccum for its own output in that case. var tangent = (duv2.Y * edge1 - duv1.Y * edge2) * r; tangentAccum[i0] += tangent; tangentAccum[i1] += tangent; tangentAccum[i2] += tangent; @@ -125,7 +125,7 @@ public static float[] ComputeTangents(float[] positions, float[] uvs, float[] no return result; } - // Fires once, on the first mesh loaded with real decoded tangent data — the meaning of + // Fires once, on the first mesh loaded with real decoded tangent data - the meaning of // VertexFormat0/1's second packed word as specifically a *tangent* (not e.g. a bitangent, and // with the assumed handedness) was never independently verified. InsomniaToolset's own // extract_gltf.cpp only decodes the adjacent word as Normal and never touches this one at all, diff --git a/ReLunacy.Engine/Assets/Geometry/PlacedInstance.cs b/ReLunacy.Engine/Assets/Geometry/PlacedInstance.cs index 9435696..ea65021 100644 --- a/ReLunacy.Engine/Assets/Geometry/PlacedInstance.cs +++ b/ReLunacy.Engine/Assets/Geometry/PlacedInstance.cs @@ -14,11 +14,12 @@ public sealed class PlacedInstance : IPlacedInstance where TAsse public float Scale { get; init; } public ushort Group { get; init; } public float DisplayDistance { get; init; } = -1f; + public float UpdateDistance { get; init; } = -1f; public ushort LightmapIndex { get; init; } = 0xFFFF; private readonly Matrix4x4? _rawMatrix; - public PlacedInstance(TAsset asset, Transform3D transform, ulong tuid, ushort group = 0, string name = "", float displayDistance = -1f) + public PlacedInstance(TAsset asset, Transform3D transform, ulong tuid, ushort group = 0, string name = "", float displayDistance = -1f, float updateDistance = -1f) { Asset = asset ?? throw new ArgumentNullException(nameof(asset)); Position = transform.Position; @@ -28,6 +29,7 @@ public PlacedInstance(TAsset asset, Transform3D transform, ulong tuid, ushort gr Name = name; ID = tuid; DisplayDistance = displayDistance; + UpdateDistance = updateDistance; } public PlacedInstance(TAsset asset, Vector3 position, Vector3 rotation, float scale, ulong tuid, ushort group = 0, string name = "") diff --git a/ReLunacy.Engine/Assets/Interfaces/IGeometry.cs b/ReLunacy.Engine/Assets/Interfaces/IGeometry.cs index 7ed1285..6c257c6 100644 --- a/ReLunacy.Engine/Assets/Interfaces/IGeometry.cs +++ b/ReLunacy.Engine/Assets/Interfaces/IGeometry.cs @@ -9,12 +9,20 @@ public interface IGeometry : IAsset float[]? GetNormals(); /// Per-vertex tangent, 4 floats per vertex (xyz direction + w bitangent-handedness - /// sign, matching glTF's TANGENT accessor convention) — see GeometryMath.ComputeTangents for + /// sign, matching glTF's TANGENT accessor convention) - see GeometryMath.ComputeTangents for /// how it's derived, including why `w` is always computed rather than read from source data. float[]? GetTangents(); + /// Per-vertex LIGHTMAP UV, 2 floats per vertex, or null when this geometry has none. + /// Baked lighting is sampled here, not at GetTextureCoordinates() - the game's own tie vertex + /// program routes this attribute to tc0.zw and its base UV to tc0.xy (see + /// Loading.Vertices.TieLightmapUV). Currently supplied by ties only; UFrags carry their + /// equivalent through IUFrag.GetLightmapUVs() instead, since they don't go through + /// GeometryData. + float[]? GetLightmapUVs(); + /// Per-vertex decode of VertexFormat0's boneIndex-as-alpha candidate (see - /// Material.UsesVertexAlphaCandidate) — null for geometry that doesn't carry it. Not + /// Material.UsesVertexAlphaCandidate) - null for geometry that doesn't carry it. Not /// necessarily meaningful data even when non-null; callers gate use on the material flag. float[]? GetVertexAlphaCandidates(); @@ -22,7 +30,7 @@ public interface IGeometry : IAsset Vector3 GetBoundingCenter(); float GetBoundingRadius(); - /// Per-vertex skin bindings, 4 slots per vertex (flat arrays, vertexCount*4 long) — + /// Per-vertex skin bindings, 4 slots per vertex (flat arrays, vertexCount*4 long) - /// null if this geometry has no skin data. GetJointIndices entries are skeleton-global bone /// indices (see IMoby.Skeleton), already resolved through the source format's per-primitive /// joint palette; -1 marks an unused slot. GetJointWeights entries are 0 for unused slots. diff --git a/ReLunacy.Engine/Assets/Interfaces/IMaterial.cs b/ReLunacy.Engine/Assets/Interfaces/IMaterial.cs index 897625e..d2c0826 100644 --- a/ReLunacy.Engine/Assets/Interfaces/IMaterial.cs +++ b/ReLunacy.Engine/Assets/Interfaces/IMaterial.cs @@ -24,39 +24,48 @@ public interface IMaterial : IAsset // ShaderMetadata yet, so consumers sample it at the base UV. ITexture? DetailTexture { get; } RenderMode RenderMode { get; } + + /// The game's OWN rendering mode byte (ShaderMetadataOld 0x11): 0 Opaque, 1 Overlay, + /// 2 Additive, 3 Scunge, 4 Cutout, 5 Soft-Edge, 6 Blended. above is a + /// lossy 4-value simplification of this - it cannot express Additive's SrcAlpha/One, Overlay's + /// polygon offset, or Soft-Edge's two-pass depth-prepass. Renderers that want the game's real + /// blend/depth/alpha states use THIS; see dev/chatgpt-eboot-{1,2,3}.txt for the EBOOT reverse + /// that established each mode's exact RSX state. + byte GameRenderMode { get; } + float AlphaClipThreshold { get; } // Per-material parallax remap, applied as height * ParallaxScale + ParallaxBias exactly as the // captured game shader does (both are fragment constants there). Read from ShaderMetadataOld // 0x50/0x54. The new engine's metadata has no identified equivalent, so new-engine materials - // report 0/0 — which disables parallax rather than substituting an invented value. + // report 0/0 - which disables parallax rather than substituting an invented value. float ParallaxScale { get; } float ParallaxBias { get; } - // Detail-map UV tiling, from ShaderMetadataOld 0x58 — detail maps are authored small and tile + // Detail-map UV tiling, from ShaderMetadataOld 0x58 - detail maps are authored small and tile // above the base map's frequency. Not recoverable from the captured fragment shader (the // detail UV arrives pre-tiled in a vertex interpolant there), which is why it lives in the - // metadata. New-engine materials report 0, meaning "not identified" — see + // metadata. New-engine materials report 0, meaning "not identified" - see // MaterialReader.GetDetailTiling for how that is distinguished from a real zero. float DetailTiling { get; } - // Per-channel detail-map strengths, from ShaderMetadataOld 0x28/0x2C/0x30 — HYPOTHESISED - // offsets, see that struct. DetailAlbedoStrength is parsed and surfaced for verification but - // deliberately NOT applied by the renderer; see AssetManager.GetOrBuildMaterial. - float DetailNormalStrength { get; } - float DetailSpecStrength { get; } - float DetailAlbedoStrength { get; } + // (There are no per-channel detail-map strengths here. The floats previously read as + // DetailNormalStrength/DetailSpecStrength/DetailAlbedoStrength at ShaderMetadataOld 0x28/0x2C/0x30 + // were misplaced - 0x20/0x24/0x28 is an RGB parameter triple, proven by the EBOOT reverse + // (dev/chatgpt-eboot-{4,5}.txt) - so they have been removed rather than left feeding the shader + // values that mean something else entirely.) // The material's own "this shader uses a detail map" flag, from the feature bitfield at // ShaderMetadataOld 0x10 (InsomniaToolset's MaterialV1_5.useDetailMap). This is authoritative // where the previous DXT1-alpha heuristic was only inferential: it says what the material // declares, rather than guessing from whether the expensive map happens to have an alpha - // channel to hold a mask. New-engine materials report false — that byte isn't identified in - // their metadata — so they fall back to "has a detail texture" alone. + // channel to hold a mask. New-engine materials report false - that byte isn't identified in + // their metadata - so they fall back to "has a detail texture" alone. bool UsesDetailMap { get; } - // See Material.UsesVertexAlphaCandidate — true when this material's render mode blends and - // its albedo has no format-level alpha channel, the one condition we've confirmed a per-vertex - // alpha candidate (VertexFormat0.boneIndex) actually correlates with real fade behavior. + // See Material.UsesVertexAlphaCandidate - true whenever this material's render mode isn't Opaque. bool UsesVertexAlphaCandidate { get; } + + // See Material.AlbedoHasAlphaChannel - true when the albedo texture's format carries real alpha. + bool AlbedoHasAlphaChannel { get; } } diff --git a/ReLunacy.Engine/Assets/Interfaces/IMesh.cs b/ReLunacy.Engine/Assets/Interfaces/IMesh.cs index 90c68db..9b762e7 100644 --- a/ReLunacy.Engine/Assets/Interfaces/IMesh.cs +++ b/ReLunacy.Engine/Assets/Interfaces/IMesh.cs @@ -6,7 +6,7 @@ public interface IMesh IMaterial Material { get; } string? Name { get; } - // For the Asset Viewer's raw-vertex inspector — null where a mesh's source format doesn't + // For the Asset Viewer's raw-vertex inspector - null where a mesh's source format doesn't // (yet) support this (e.g. UFrags). VertexFormatName identifies which raw vertex struct this // mesh was read with (VertexFormat0/1, old vs. new engine); DumpVertex returns a formatted // dump of one vertex's raw+decoded fields by index, or null if out of range. diff --git a/ReLunacy.Engine/Assets/Interfaces/IPlacedInstance.cs b/ReLunacy.Engine/Assets/Interfaces/IPlacedInstance.cs index 4429563..0a867d1 100644 --- a/ReLunacy.Engine/Assets/Interfaces/IPlacedInstance.cs +++ b/ReLunacy.Engine/Assets/Interfaces/IPlacedInstance.cs @@ -6,7 +6,7 @@ public interface IPlacedInstance where TAsset : IAsset { TAsset Asset { get; } Vector3 Position { get; } - /// ZYX Euler angles. Radians for Mobys (raw from file); unused for Ties, which carry an exact placement matrix instead — see . + /// ZYX Euler angles. Radians for Mobys (raw from file); unused for Ties, which carry an exact placement matrix instead - see . Vector3 Rotation { get; } float Scale { get; } public ulong ID { get; set; } @@ -17,10 +17,17 @@ public interface IPlacedInstance where TAsset : IAsset /// Distance (in-game units) beyond which the game itself stops rendering this instance. < 0 means unlimited. Only Mobys carry this from the file; other instance types default to unlimited. public float DisplayDistance { get; init; } + /// Distance (in-game units) beyond which the game stops updating this instance's logic (a + /// separate budget from , which only gates rendering). < 0 means + /// unlimited. Only Mobys carry this from the file; other instance types default to unlimited. Not + /// used by anything in ReLunacy's own rendering/culling - carried purely so it's visible and + /// editable in the Property Inspector, the way ReLunacy-Ymir exposes it. + public float UpdateDistance { get; init; } + /// This instance's entry in the level's baked lighting lists (LevelData.ZoneLightmaps /// / ZoneDirectionals), or 0xFFFF for none. PER-INSTANCE, not per-asset: measured on /// metropolis, 1728 of 4848 tie instances carry one and every index is distinct, i.e. one - /// unique bake per placement. Only ties populate it today — see TieInstance.LightmapIndex. + /// unique bake per placement. Only ties populate it today - see TieInstance.LightmapIndex. public ushort LightmapIndex { get; init; } Matrix4x4 GetTransformMatrix(); diff --git a/ReLunacy.Engine/Assets/Interfaces/ISkeleton.cs b/ReLunacy.Engine/Assets/Interfaces/ISkeleton.cs index 0cbd543..bba60a8 100644 --- a/ReLunacy.Engine/Assets/Interfaces/ISkeleton.cs +++ b/ReLunacy.Engine/Assets/Interfaces/ISkeleton.cs @@ -9,7 +9,7 @@ public interface IBone int ParentIndex { get; } /// Bind-pose transform in moby-local space (not relative to the parent bone). Matrix4x4 WorldBindPose { get; } - /// Inverse of WorldBindPose — the matrix GPU skinning multiplies a vertex by. + /// Inverse of WorldBindPose - the matrix GPU skinning multiplies a vertex by. Matrix4x4 InverseBindPose { get; } } diff --git a/ReLunacy.Engine/Assets/Interfaces/ITie.cs b/ReLunacy.Engine/Assets/Interfaces/ITie.cs index 0a182ee..9081394 100644 --- a/ReLunacy.Engine/Assets/Interfaces/ITie.cs +++ b/ReLunacy.Engine/Assets/Interfaces/ITie.cs @@ -8,4 +8,21 @@ public interface ITie : IAsset IReadOnlyList Meshes { get; } float Scale { get; } (Vector3 center, float radius) GetBoundingSphere(); + + /// LIGHTMAP UV channel, flat as [u0, v0, u1, v1, ...], or null when this tie has none. + /// Mirrors IUFrag.GetLightmapUVs(), with one structural difference worth knowing: a UFrag's + /// UVs address an island inside a shared zone atlas, whereas a tie's address its own private + /// baked texture - every lightmapped tie INSTANCE gets a distinct entry in zone sections 0x5400 + /// and 0x5410 (see TieInstance.LightmapIndex), so these UVs cover the full [0,1] square and are + /// shared by every instance of the asset while the texture they sample is not. + /// + /// Indexed over the TIE's whole vertex buffer, not per mesh - TieMesh.verticesIndex is the + /// offset of a mesh's first vertex into this array. + /// + /// Non-null for the ties whose array is at the known location (61 of 193 on metropolis) and null + /// for the rest, which therefore render unlit. That is deliberate: a wrong offset would produce + /// in-range, plausible-looking UVs and light the tie incorrectly, which is much harder to notice + /// than no lighting at all. See Loading.Vertices.TieLightmapUV for the format, the evidence, and + /// where the remaining arrays are NOT. + float[]? GetLightmapUVs(); } diff --git a/ReLunacy.Engine/Assets/Interfaces/IUFrag.cs b/ReLunacy.Engine/Assets/Interfaces/IUFrag.cs index 5e75c6a..3df71c6 100644 --- a/ReLunacy.Engine/Assets/Interfaces/IUFrag.cs +++ b/ReLunacy.Engine/Assets/Interfaces/IUFrag.cs @@ -2,18 +2,18 @@ namespace ReLunacy.Engine.Assets.Interfaces; -/// Terrain geometry baked directly into zones — not instanced. +/// Terrain geometry baked directly into zones - not instanced. public interface IUFrag : IAsset { float[] GetVertexPositions(); float[] GetTextureCoordinates(); - /// Second UV set — the LIGHTMAP UV channel (UFragVertex.UVs2). The game samples its + /// Second UV set - the LIGHTMAP UV channel (UFragVertex.UVs2). The game samples its /// baked light colour (zone 0x5400) and light direction (0x5410) maps here rather than at the /// base UV. Null when the source carried none. float[]? GetLightmapUVs(); - /// Index into this zone's baked lighting texture lists — the same index selects the + /// Index into this zone's baked lighting texture lists - the same index selects the /// entry in both 0x5400 and 0x5410. UFragMetadata.NoLightmap (0xFFFF) when this UFrag has /// none. Per-INSTANCE, not per-material: see AssetManager.GetOrBuildMaterial, whose cache is /// keyed on (shader TUID, this index) precisely because one shader is shared across UFrags @@ -22,16 +22,21 @@ public interface IUFrag : IAsset float[]? GetNormals(); float[]? GetTangents(); + /// Per-vertex alpha decoded from UFragVertex.unk (see its VertexAlphaCandidate - same + /// field role and decode as Ties' VertexFormat0.boneIndex; UFrags have no skeleton either, so + /// there's no competing bone-index use of the field the way there is on Mobys). Null for a + /// UFrag whose material has no use for it - see Material.UsesVertexAlphaCandidate. + float[]? GetVertexAlphaCandidates(); uint[] GetIndices(); IMaterial Material { get; } - /// World-space placement anchor: local (0,0,0) of GetVertexPositions() maps here. Distinct from the bounding sphere below — see GetAnchor/GetBoundingCenter split in ZoneReader.ConvertUFrag. + /// World-space placement anchor: local (0,0,0) of GetVertexPositions() maps here. Distinct from the bounding sphere below - see GetAnchor/GetBoundingCenter split in ZoneReader.ConvertUFrag. Vector3 GetAnchor(); - /// True bounding-sphere center (world-space), for culling — not the placement anchor. + /// True bounding-sphere center (world-space), for culling - not the placement anchor. Vector3 GetBoundingCenter(); float GetBoundingRadius(); bool IsOldEngine { get; } - /// The raw parsed 0x6200 record, for the Asset Viewer's UFrag tab — same "show what the + /// The raw parsed 0x6200 record, for the Asset Viewer's UFrag tab - same "show what the /// file actually says, not what we decided it means" role that Shader.metadataOld plays for the /// Shader Browser. Nothing in the render path reads this. Loading.Objects.UFragMetadata? Metadata { get; } diff --git a/ReLunacy.Engine/Assets/Lighting/LightingEnvironment.cs b/ReLunacy.Engine/Assets/Lighting/LightingEnvironment.cs new file mode 100644 index 0000000..b24ca57 --- /dev/null +++ b/ReLunacy.Engine/Assets/Lighting/LightingEnvironment.cs @@ -0,0 +1,29 @@ +using System.Numerics; + +namespace ReLunacy.Engine.Assets.Lighting; + +/// One directional light of a : a colour (carrying its own +/// HDR magnitude - there is no separate intensity field) and a unit direction, taken as pointing +/// TOWARD the light. +public sealed class DirectionalLight +{ + public Vector3 Colour { get; set; } + public Vector3 Direction { get; set; } +} + +/// The level's analytic lighting environment - old-engine main.dat section 0x8b00, one +/// record per level. Reverse-engineered by matching its floats to a RenderDoc capture of the game's +/// fragment constant bank. An ambient colour plus a list of directional lights. +/// +/// The record has room for two directions (0x50/0x60) and three colours (0x20 ambient, 0x30/0x40 +/// per light), and its header's first word is a count (2 on both levels seen). We DON'T assume a +/// level always has exactly two: the reader builds from whichever direction +/// slots are actually populated, so a level with one - or none - is handled. If a future level's +/// record is larger than 0x80 it carries more than two and the reader would need extending. +public sealed class LightingEnvironment +{ + // Mutable so the Level Data frame can live-tune them (View3D re-reads these into the renderer + // every frame). Reloading the level restores the file values. + public Vector3 Ambient { get; set; } + public List Lights { get; } = []; +} diff --git a/ReLunacy.Engine/Assets/Materials/Material.cs b/ReLunacy.Engine/Assets/Materials/Material.cs index ecee469..591647a 100644 --- a/ReLunacy.Engine/Assets/Materials/Material.cs +++ b/ReLunacy.Engine/Assets/Materials/Material.cs @@ -14,23 +14,26 @@ public sealed class Material : IMaterial public ITexture? DetailTexture { get; init; } public RenderMode RenderMode { get; init; } + /// The game's own rendering-mode byte (0-6) - see IMaterial.GameRenderMode. + public byte GameRenderMode { get; init; } public float AlphaClipThreshold { get; init; } public float ParallaxScale { get; init; } public float ParallaxBias { get; init; } public float DetailTiling { get; init; } - public float DetailNormalStrength { get; init; } - public float DetailSpecStrength { get; init; } - public float DetailAlbedoStrength { get; init; } public bool UsesDetailMap { get; init; } - // True when this material's render mode blends (Overlay/SoftEdge/Blended — the game's - // RenderingMode, not this simplified RenderMode) and its albedo has no format-level alpha - // channel to source transparency from. The only place we've confirmed a per-vertex alpha - // candidate actually varies meaningfully is on meshes matching this condition — see - // VertexFormat0's boneIndex field and AssetManager, which only writes decoded vertex alpha - // into the vColor attribute for materials with this flag set. + // True when this material's render mode is anything but Opaque (the game's RenderingMode, not + // this simplified RenderMode) - see MaterialReader.UsesVertexAlphaCandidate for the reasoning. + // AssetManager only writes decoded vertex alpha into the vColor attribute for materials with + // this flag set; every other material's vertices get a synthetic fully-opaque alpha instead. public bool UsesVertexAlphaCandidate { get; init; } + // Whether the albedo texture's own format carries real alpha bits (see MaterialReader's + // HasAlphaChannel). The shader uses this to decide whether the albedo's alpha is meaningful + // enough to fold into the final opacity alongside vertex alpha, or whether it would just be + // multiplying in garbage from a format that has no alpha channel to begin with. + public bool AlbedoHasAlphaChannel { get; init; } + public Material(ulong id) { Id = id; @@ -38,7 +41,7 @@ public Material(ulong id) AlphaClipThreshold = 0.5f; } - public static Material Create(ulong id, ITexture? albedo = null, ITexture? normal = null, ITexture? properties = null, ITexture? detail = null, RenderMode renderMode = RenderMode.Opaque, float alphaClipThreshold = 0.01f, bool usesVertexAlphaCandidate = false, float parallaxScale = 0f, float parallaxBias = 0f, float detailTiling = 0f, float detailNormalStrength = 0f, float detailSpecStrength = 0f, float detailAlbedoStrength = 0f, bool usesDetailMap = false) + public static Material Create(ulong id, ITexture? albedo = null, ITexture? normal = null, ITexture? properties = null, ITexture? detail = null, RenderMode renderMode = RenderMode.Opaque, byte gameRenderMode = 0, float alphaClipThreshold = 0.01f, bool usesVertexAlphaCandidate = false, bool albedoHasAlphaChannel = false, float parallaxScale = 0f, float parallaxBias = 0f, float detailTiling = 0f, bool usesDetailMap = false) { return new Material(id) { @@ -47,14 +50,13 @@ public static Material Create(ulong id, ITexture? albedo = null, ITexture? norma PropertiesTexture = properties, DetailTexture = detail, RenderMode = renderMode, + GameRenderMode = gameRenderMode, AlphaClipThreshold = alphaClipThreshold, UsesVertexAlphaCandidate = usesVertexAlphaCandidate, + AlbedoHasAlphaChannel = albedoHasAlphaChannel, ParallaxScale = parallaxScale, ParallaxBias = parallaxBias, DetailTiling = detailTiling, - DetailNormalStrength = detailNormalStrength, - DetailSpecStrength = detailSpecStrength, - DetailAlbedoStrength = detailAlbedoStrength, UsesDetailMap = usesDetailMap }; } diff --git a/ReLunacy.Engine/Assets/Mobys/Bangle.cs b/ReLunacy.Engine/Assets/Mobys/Bangle.cs index 9d501bd..5a5aa54 100644 --- a/ReLunacy.Engine/Assets/Mobys/Bangle.cs +++ b/ReLunacy.Engine/Assets/Mobys/Bangle.cs @@ -2,7 +2,7 @@ namespace ReLunacy.Engine.Assets.Mobys; -/// Group of meshes enabled/disabled at runtime on an — character skins, NPC variations, etc. +/// Group of meshes enabled/disabled at runtime on an - character skins, NPC variations, etc. public sealed class Bangle : IBangle { public IReadOnlyList Meshes { get; init; } diff --git a/ReLunacy.Engine/Assets/Primitives/Transform3D.cs b/ReLunacy.Engine/Assets/Primitives/Transform3D.cs index e5c8867..c21cfed 100644 --- a/ReLunacy.Engine/Assets/Primitives/Transform3D.cs +++ b/ReLunacy.Engine/Assets/Primitives/Transform3D.cs @@ -5,7 +5,7 @@ namespace ReLunacy.Engine.Assets.Primitives; public readonly struct Transform3D { public Vector3 Position { get; init; } - /// ZYX Euler angles in radians, straight from the file (see MobyInstanceOld/New) — no unit conversion. + /// ZYX Euler angles in radians, straight from the file (see MobyInstanceOld/New) - no unit conversion. public Vector3 Rotation { get; init; } public float Scale { get; init; } diff --git a/ReLunacy.Engine/Assets/Terrain/NewUFrag.cs b/ReLunacy.Engine/Assets/Terrain/NewUFrag.cs index 40ea0b2..0342ff5 100644 --- a/ReLunacy.Engine/Assets/Terrain/NewUFrag.cs +++ b/ReLunacy.Engine/Assets/Terrain/NewUFrag.cs @@ -3,7 +3,7 @@ namespace ReLunacy.Engine.Assets.Terrain; -/// New-engine UFrag: direct terrain geometry baked into zones — not instanced. +/// New-engine UFrag: direct terrain geometry baked into zones - not instanced. public sealed class NewUFrag : IUFrag { public ulong Id { get; init; } @@ -19,6 +19,7 @@ public sealed class NewUFrag : IUFrag // Second UV set = lightmap UVs, and the per-instance index selecting this UFrag's entry in the // zone's baked light colour (0x5400) / light direction (0x5410) lists. See IUFrag. private readonly float[]? _lightmapUVs; + private readonly float[]? _vertexAlphaCandidates; private readonly uint[] _indices; private readonly Vector3 _anchor; private readonly Vector3 _boundingCenter; @@ -36,6 +37,7 @@ public NewUFrag( float[]? normals = null, float[]? tangents = null, float[]? lightmapUVs = null, + float[]? vertexAlphaCandidates = null, ushort lightmapIndex = Loading.Objects.UFragMetadata.NoLightmap, Loading.Objects.UFragMetadata? metadata = null, string? name = null) @@ -49,6 +51,7 @@ public NewUFrag( _normals = normals; _tangents = tangents; _lightmapUVs = lightmapUVs; + _vertexAlphaCandidates = vertexAlphaCandidates; LightmapIndex = lightmapIndex; Metadata = metadata; _anchor = anchor; @@ -95,6 +98,7 @@ public NewUFrag( public Loading.Objects.UFragMetadata? Metadata { get; init; } public float[]? GetNormals() => _normals; public float[]? GetTangents() => _tangents; + public float[]? GetVertexAlphaCandidates() => _vertexAlphaCandidates; public uint[] GetIndices() => _indices; public Vector3 GetAnchor() => _anchor; public Vector3 GetBoundingCenter() => _boundingCenter; diff --git a/ReLunacy.Engine/Assets/Terrain/OldUFrag.cs b/ReLunacy.Engine/Assets/Terrain/OldUFrag.cs index 7d2bf5f..961039c 100644 --- a/ReLunacy.Engine/Assets/Terrain/OldUFrag.cs +++ b/ReLunacy.Engine/Assets/Terrain/OldUFrag.cs @@ -3,7 +3,7 @@ namespace ReLunacy.Engine.Assets.Terrain; -/// Old-engine UFrag: direct terrain geometry baked into zones — not instanced. +/// Old-engine UFrag: direct terrain geometry baked into zones - not instanced. public sealed class OldUFrag : IUFrag { public ulong Id { get; init; } @@ -19,6 +19,7 @@ public sealed class OldUFrag : IUFrag // Second UV set = lightmap UVs, and the per-instance index selecting this UFrag's entry in the // zone's baked light colour (0x5400) / light direction (0x5410) lists. See IUFrag. private readonly float[]? _lightmapUVs; + private readonly float[]? _vertexAlphaCandidates; private readonly uint[] _indices; private readonly Vector3 _anchor; private readonly Vector3 _boundingCenter; @@ -36,6 +37,7 @@ public OldUFrag( float[]? normals = null, float[]? tangents = null, float[]? lightmapUVs = null, + float[]? vertexAlphaCandidates = null, ushort lightmapIndex = Loading.Objects.UFragMetadata.NoLightmap, Loading.Objects.UFragMetadata? metadata = null, string? name = null) @@ -49,6 +51,7 @@ public OldUFrag( _normals = normals; _tangents = tangents; _lightmapUVs = lightmapUVs; + _vertexAlphaCandidates = vertexAlphaCandidates; LightmapIndex = lightmapIndex; Metadata = metadata; _anchor = anchor; @@ -93,6 +96,7 @@ public OldUFrag( public Loading.Objects.UFragMetadata? Metadata { get; init; } public float[]? GetNormals() => _normals; public float[]? GetTangents() => _tangents; + public float[]? GetVertexAlphaCandidates() => _vertexAlphaCandidates; public uint[] GetIndices() => _indices; public Vector3 GetAnchor() => _anchor; public Vector3 GetBoundingCenter() => _boundingCenter; diff --git a/ReLunacy.Engine/Assets/Ties/Tie.cs b/ReLunacy.Engine/Assets/Ties/Tie.cs index 1ddbc25..cb2a3c6 100644 --- a/ReLunacy.Engine/Assets/Ties/Tie.cs +++ b/ReLunacy.Engine/Assets/Ties/Tie.cs @@ -13,6 +13,10 @@ public sealed class Tie : ITie public IReadOnlyList Meshes { get; init; } public float Scale { get; init; } + /// Backing store for - see ITie for the shape and for why + /// nothing sets it yet. + private readonly float[]? _lightmapUVs; + private readonly Lazy<(Vector3 center, float radius)>? _boundingSphere; public Tie( @@ -20,12 +24,14 @@ public Tie( IReadOnlyList meshes, float scale = 1.0f, string? name = null, - Func<(Vector3, float)>? boundingSphereCalculator = null) + Func<(Vector3, float)>? boundingSphereCalculator = null, + float[]? lightmapUVs = null) { Id = id; Name = name; Meshes = meshes ?? throw new ArgumentNullException(nameof(meshes)); Scale = scale; + _lightmapUVs = lightmapUVs; IsLoaded = true; if (boundingSphereCalculator != null) @@ -34,6 +40,9 @@ public Tie( } } + /// + public float[]? GetLightmapUVs() => _lightmapUVs; + public (Vector3 center, float radius) GetBoundingSphere() { if (_boundingSphere != null) diff --git a/ReLunacy.Engine/Diagnostics/FrameProfiler.cs b/ReLunacy.Engine/Diagnostics/FrameProfiler.cs new file mode 100644 index 0000000..1beea71 --- /dev/null +++ b/ReLunacy.Engine/Diagnostics/FrameProfiler.cs @@ -0,0 +1,282 @@ +using System.Diagnostics; + +namespace ReLunacy.Engine.Diagnostics; + +/// Per-frame CPU wall-clock profiler that attributes the frame's time to named phases, so +/// it can answer "where does the frame go?" - CPU command recording, CPU submission, the GPU-idle +/// stall, present, or any other pass. Lives in ReLunacy.Engine (not the app) so engine-side render +/// code - the forward renderer especially - can self-instrument the passes it owns. +/// +/// It is a CPU profiler by design: Veldrith (the Veldrid fork this project uses) exposes no GPU +/// timestamp query pool, so there is no in-API way to read how long the GPU itself spent on a pass. +/// What CAN be measured precisely is the CPU cost of building and submitting command lists, and - +/// because the app calls WaitForIdle() once per frame - the time the CPU sits BLOCKED waiting for +/// the GPU to drain everything submitted this frame. That WaitForIdle span (phase "GPU Wait") is +/// therefore the honest proxy for the GPU tail: if it dominates while the record/submit phases are +/// cheap, the frame is GPU- or sync-bound; if the record/submit phases dominate, it is CPU-bound. +/// See . +/// +/// Everything runs on the single main-loop thread (the same thread records commands, submits, and +/// later reads these numbers to draw the profiler UI), so there are no locks. Phases nest: a +/// scope opened inside another is recorded one level deeper, which is what +/// lets "Draw Record" sit under "Renderer Flush" under "3D Record" in the readout. A phase entered +/// more than once in a frame accumulates; its per-frame total is what folds into the rolling +/// average. tracks non-time quantities (draw calls, renderable counts) - +/// the single most diagnostic numbers for a CPU-bound forward renderer. +public sealed class FrameProfiler +{ + public static FrameProfiler Singleton { get; } = new(); + + /// When false, returns an inert scope and Begin/EndFrame do + /// nothing - kept cheap so the instrumentation can stay in the hot loop unconditionally. The + /// profiler UI flips this on while it is open. + public static bool Enabled; + + /// Rolling-average window, in frames. 120 ~ 2 s at 60 fps / longer when slow, which is + /// enough to smooth out per-frame jitter without lagging behind a real change in cost. + private const int SampleCount = 120; + + public const string RootPhase = "Frame"; + + /// Name of the WaitForIdle stall phase - the GPU-tail proxy (see class summary). The + /// verdict and the UI treat this one specially, so it is a named constant rather than a literal + /// scattered around. + public const string GpuWaitPhase = "GPU Wait"; + + /// Name of the SwapBuffers/present phase. Separated from the GPU-wait tail because with + /// VSync on it blocks to hit the refresh interval - a capped, intended wait, not a bottleneck to + /// optimise - so the verdict must not lump it in with real GPU cost. + public const string PresentPhase = "Present"; + + internal sealed class PhaseData + { + public required string Name; + public int Order; + public int Depth; + public double CurrentMs; + public double LastMs; + private readonly float[] ring = new float[SampleCount]; + private int ringCount; + private int ringHead; + + public void Commit() + { + LastMs = CurrentMs; + ring[ringHead] = (float)CurrentMs; + ringHead = (ringHead + 1) % ring.Length; + if (ringCount < ring.Length) ringCount++; + CurrentMs = 0; + } + + public double AvgMs + { + get + { + if (ringCount == 0) return 0; + double sum = 0; + for (int i = 0; i < ringCount; i++) sum += ring[i]; + return sum / ringCount; + } + } + } + + /// One phase's numbers as handed to the UI. Depth drives indentation; Percent is of the + /// whole frame so the columns read as a breakdown. + public readonly record struct PhaseSnapshot(string Name, int Depth, double LastMs, double AvgMs, double Percent); + + private readonly Dictionary phases = new(32); + private readonly Stack open = new(); + private int nextOrder; + private int currentDepth; + + // Non-time counters (draw calls, renderable counts). Insertion-ordered for a stable readout; the + // value is the last one set, not averaged - a count is already an exact per-frame number. + private readonly Dictionary counters = new(8); + private readonly List counterOrder = []; + + private PhaseData GetOrAdd(string name) + { + if (phases.TryGetValue(name, out var p)) return p; + p = new PhaseData { Name = name, Order = nextOrder++ }; + phases[name] = p; + return p; + } + + public static void BeginFrame() + { + if (!Enabled) return; + var self = Singleton; + self.currentDepth = 0; + self.open.Clear(); + // The root spans the entire frame; opened here and closed in EndFrame so every other phase + // nests one level under it and Percent has a denominator. + var root = self.GetOrAdd(RootPhase); + root.Depth = 0; + self.open.Push(root); + self.currentDepth = 1; + self.rootStart = Stopwatch.GetTimestamp(); + } + + private long rootStart; + + public static void EndFrame() + { + if (!Enabled) return; + var self = Singleton; + + if (self.open.Count > 0) + { + var root = self.open.Pop(); + root.CurrentMs += ToMs(Stopwatch.GetTimestamp() - self.rootStart); + } + + // Commit every KNOWN phase, not just the ones touched this frame: a phase that ran last + // frame but not this one must fold a 0 into its average, otherwise a phase that stops + // happening keeps reporting its old cost forever. + foreach (var p in self.phases.Values) + p.Commit(); + } + + /// Opens a timing scope for . Dispose (via a using statement) + /// closes it and adds the elapsed time to that phase's running total for the frame. + public static Scope Sample(string name) + { + if (!Enabled) return default; + var self = Singleton; + var p = self.GetOrAdd(name); + p.Depth = self.currentDepth; + self.open.Push(p); + self.currentDepth++; + return new Scope(self, p, Stopwatch.GetTimestamp()); + } + + /// Records a non-time quantity for this frame (e.g. "Draw calls"). No-op unless + /// profiling is enabled, so it is safe to leave in the render hot path. + public static void SetCounter(string name, long value) + { + if (!Enabled) return; + var self = Singleton; + if (!self.counters.ContainsKey(name)) self.counterOrder.Add(name); + self.counters[name] = value; + } + + /// Adds to a per-frame counter - for tallies accumulated across many calls, like the + /// draw-call count summed as each pass records. Reset to 0 for the frame by + /// at the start of the owning pass. + public static void AddCounter(string name, long delta) + { + if (!Enabled) return; + var self = Singleton; + if (!self.counters.ContainsKey(name)) self.counterOrder.Add(name); + self.counters.TryGetValue(name, out long cur); + self.counters[name] = cur + delta; + } + + private void Close(PhaseData p, long startTicks) + { + p.CurrentMs += ToMs(Stopwatch.GetTimestamp() - startTicks); + if (open.Count > 0) open.Pop(); + currentDepth = Math.Max(1, currentDepth - 1); + } + + private static double ToMs(long ticks) => ticks * 1000.0 / Stopwatch.Frequency; + + /// Snapshot of every phase for the current window, ordered as they were first seen + /// (i.e. top-to-bottom in frame order). Safe to call from the UI on the main thread. + public IReadOnlyList Snapshot() + { + double frameAvg = phases.TryGetValue(RootPhase, out var root) ? root.AvgMs : 0; + var list = new List(phases.Count); + foreach (var p in phases.Values) + { + double pct = frameAvg > 0 ? p.AvgMs / frameAvg * 100.0 : 0; + list.Add(new PhaseSnapshot(p.Name, p.Depth, p.LastMs, p.AvgMs, pct)); + } + list.Sort((a, b) => OrderOf(a.Name).CompareTo(OrderOf(b.Name))); + return list; + } + + /// Per-frame counters (draw calls, renderable counts...) in first-seen order. + public IReadOnlyList<(string Name, long Value)> Counters() + { + var list = new List<(string, long)>(counterOrder.Count); + foreach (var name in counterOrder) + list.Add((name, counters.TryGetValue(name, out long v) ? v : 0)); + return list; + } + + private int OrderOf(string name) => phases.TryGetValue(name, out var p) ? p.Order : int.MaxValue; + + public double FrameAvgMs => phases.TryGetValue(RootPhase, out var root) ? root.AvgMs : 0; + public double PhaseAvgMs(string name) => phases.TryGetValue(name, out var p) ? p.AvgMs : 0; + + public enum Bound { Unknown, Cpu, GpuOrSync, Present } + + public readonly record struct FrameVerdict(Bound Bound, string Headline, string Detail); + + /// Classifies the frame into where its time actually goes, from the three unambiguous + /// buckets: the GPU-idle stall (), present/VSync + /// (), and everything else the CPU actively did (frame - those two). + /// This is the headline answer to "where should we optimise?". + public FrameVerdict Verdict() + { + double frame = FrameAvgMs; + if (frame <= 0) return new FrameVerdict(Bound.Unknown, "Collecting samples...", ""); + + double gpuWait = PhaseAvgMs(GpuWaitPhase); + double present = PhaseAvgMs(PresentPhase); + double cpuActive = Math.Max(0, frame - gpuWait - present); + + // The biggest CPU-active phase to name in the detail line - leaves only, so the answer is a + // concrete pass ("Draw Record") rather than a container ("Draw") that just re-states its total. + (string name, double ms) top = ("", 0); + foreach (var leaf in CpuLeafPhases) + { + double ms = PhaseAvgMs(leaf); + if (ms > top.ms) top = (leaf, ms); + } + + if (gpuWait >= cpuActive && gpuWait >= present) + return new FrameVerdict(Bound.GpuOrSync, + "GPU / sync bound", + $"The CPU spends {gpuWait:0.0} ms of the {frame:0.0} ms frame blocked in WaitForIdle. " + + "Either the GPU genuinely needs that long, or the per-frame full sync is stalling a " + + "GPU that could otherwise overlap the next frame's CPU work."); + + if (present > cpuActive && present > gpuWait) + return new FrameVerdict(Bound.Present, + "Present / VSync bound", + $"Most of the frame ({present:0.0} ms) is the swap waiting on the display. If VSync is " + + "on this is expected; if it is off, the driver's present queue is the limiter."); + + return new FrameVerdict(Bound.Cpu, + "CPU bound", + $"The CPU is busy {cpuActive:0.0} ms of the {frame:0.0} ms frame" + + (top.ms > 0 ? $", most of it in \"{top.name}\" ({top.ms:0.0} ms)." : ".")); + } + + /// Leaf (non-container) CPU phases the verdict may name as the hot spot. Kept in one + /// place so it stays in sync with what the instrumentation actually opens. + private static readonly string[] CpuLeafPhases = + ["Events", "ImGui NewFrame", "Scene Enqueue", "Sort", "Buffer Update", "Draw Record", + "3D Submit", "ImGui Render", "Composite"]; + + public readonly struct Scope : IDisposable + { + private readonly FrameProfiler? owner; + private readonly PhaseData? phase; + private readonly long start; + + internal Scope(FrameProfiler owner, PhaseData phase, long start) + { + this.owner = owner; + this.phase = phase; + this.start = start; + } + + public void Dispose() + { + if (owner != null && phase != null) owner.Close(phase, start); + } + } +} diff --git a/ReLunacy.Engine/Export/ExportPaths.cs b/ReLunacy.Engine/Export/ExportPaths.cs index 26a080d..2049b5d 100644 --- a/ReLunacy.Engine/Export/ExportPaths.cs +++ b/ReLunacy.Engine/Export/ExportPaths.cs @@ -1,6 +1,6 @@ namespace ReLunacy.Engine.Export; -/// Shared filename sanitization for exporters — asset/material names routinely contain +/// Shared filename sanitization for exporters - asset/material names routinely contain /// path-like characters (e.g. "levels/great_clock_a/entities/.../foo.entity.irb"), which break /// file creation if used as-is. public static class ExportPaths diff --git a/ReLunacy.Engine/Export/GltfExporter.cs b/ReLunacy.Engine/Export/GltfExporter.cs index ad3d716..d8af1d3 100644 --- a/ReLunacy.Engine/Export/GltfExporter.cs +++ b/ReLunacy.Engine/Export/GltfExporter.cs @@ -17,13 +17,13 @@ namespace ReLunacy.Engine.Export; using SkinnedMeshBuilder = MeshBuilder; using SkinnedVertex = VertexBuilder; -/// Exports engine meshes as glTF — one group (e.g. a Moby's bangle, or a Tie's whole mesh +/// Exports engine meshes as glTF - one group (e.g. a Moby's bangle, or a Tie's whole mesh /// list) becomes one glTF mesh/node, so bangles stay distinct submeshes instead of being flattened /// into a single blob. Two output modes share the same scene-building logic () and only differ in how the result is written to disk: /// packs everything (geometry, textures) into one self-contained .glb; writes a loose .gltf JSON + .bin buffer + separate texture image -/// files in the same folder — the layout sites like The Models Resource expect a submission to be +/// files in the same folder - the layout sites like The Models Resource expect a submission to be /// in, since it lets a submission be inspected/re-textured file-by-file instead of needing to be /// unpacked from a binary blob first. public static class GltfExporter @@ -35,17 +35,17 @@ public static void Export(string filePath, string modelName, IReadOnlyListSame geometry/material data as , written as a loose .gltf + - /// .bin + PNG textures instead of one packed .glb — see the class-level comment for why. All + /// .bin + PNG textures instead of one packed .glb - see the class-level comment for why. All /// resources land in 's own directory (SharpGLTF's /// ResourceWriteMode.SatelliteFile default naming), so callers should give this its own /// dedicated output folder rather than one shared with other exports. Every derived texture /// image is named after (the same name the caller put in - /// filePath) plus a type suffix — _a albedo, _n normal, _mr/_spec/_em the metallic-roughness/ - /// specular/emissive images split out of the "expensive" texture — and, when a material has + /// filePath) plus a type suffix - _a albedo, _n normal, _mr/_spec/_em the metallic-roughness/ + /// specular/emissive images split out of the "expensive" texture - and, when a material has /// more than one distinct shader (e.g. a multi-bangle Moby), later materials get a "_matN" /// disambiguator so filenames never collide. The RAW, unmodified "expensive"/detail source - /// textures — which glTF has no direct channel for and which - /// only ever consumes, never re-exposes whole — are written separately afterward as plain + /// textures - which glTF has no direct channel for and which + /// only ever consumes, never re-exposes whole - are written separately afterward as plain /// reference images (_ex / _d), not wired into the glTF material at all, so a submission still /// carries the game's actual original textures alongside the derived PBR ones. public static void ExportGltfSeparate(string filePath, string modelName, IReadOnlyList groups, ISkeleton? skeleton = null, Action? onProgress = null) @@ -58,7 +58,7 @@ public static void ExportGltfSeparate(string filePath, string modelName, IReadOn } /// Picks one name per distinct material (by first-encounter order across every group's - /// meshes) for 's texture naming — the first/most common case + /// meshes) for 's texture naming - the first/most common case /// (a single-material asset) gets exactly , so its textures come out /// named baseName_a.png etc. with no surprise suffix; only assets with more than one distinct /// material (e.g. a Moby whose bangles use different shaders) get "_matN" appended to keep @@ -79,7 +79,7 @@ private static Dictionary AssignTextureNames(IReadOnlyListWrites each distinct material's raw, unmodified PropertiesTexture ("expensive") and - /// DetailTexture straight to disk as {name}_ex.png / {name}_d.png — see + /// DetailTexture straight to disk as {name}_ex.png / {name}_d.png - see /// 's doc comment for why these bypass the glTF material /// entirely instead of being wired into a channel: neither has a natural glTF slot (the /// properties texture gets split three ways, the detail texture gets baked into other images), @@ -123,14 +123,14 @@ private static (ModelRoot Model, Dictionary? TextureNames) BuildM int processedMeshes = 0; // A skeleton with a single bone (just its own root, no children) has no real hierarchy to - // speak of — it's not "the model is animated/skinned," it's one identity-transform node + // speak of - it's not "the model is animated/skinned," it's one identity-transform node // that ConvertSkeleton still happens to produce for some non-animated Mobys. Exporting that // as a one-bone armature just adds a pointless skin/joint to the glTF for a model that, - // for every purpose that matters to an export, has no skeleton — so it's treated the same + // for every purpose that matters to an export, has no skeleton - so it's treated the same // as skeleton == null below rather than only gating on nullness. bool hasRealSkeleton = skeleton != null && skeleton.Bones.Count > 1; - // Built once and reused for every group below — every mesh of a skinned asset shares the + // Built once and reused for every group below - every mesh of a skinned asset shares the // exact same bind-pose joint hierarchy, since bind pose is a property of the asset, not of // any one submesh. (NodeBuilder Node, Matrix4x4 InverseBindMatrix)[]? jointBindings = hasRealSkeleton ? BuildSkinnedJoints(skeleton!) : null; @@ -157,7 +157,7 @@ private static (ModelRoot Model, Dictionary? TextureNames) BuildM /// /// Builds one reusable glTF mesh (each of `meshes` becomes its own primitive) that the caller - /// can attach to as many scene nodes as it wants — SharpGLTF collapses repeated + /// can attach to as many scene nodes as it wants - SharpGLTF collapses repeated /// SceneBuilder.AddRigidMesh calls against the same IMeshBuilder into one shared mesh + N /// nodes, which is how LevelExporter gets true instancing for a Moby/Tie asset placed many /// times across a level, instead of duplicating its geometry per instance. @@ -194,7 +194,7 @@ public static IMeshBuilder BuildMeshBuilder(string name, IReadO } // Winding is passed through as-is: the renderer draws these with backface culling - // disabled (RasterizerStateDescription.CULL_NONE — see AssetManager.GetOrBuildMaterial) + // disabled (RasterizerStateDescription.CULL_NONE - see AssetManager.GetOrBuildMaterial) // because winding isn't reliably consistent in the source data. DoubleSided below // mirrors that instead of guessing at a "correct" winding per-triangle. for (int i = 0; i + 2 < indices.Length; i += 3) @@ -207,7 +207,7 @@ public static IMeshBuilder BuildMeshBuilder(string name, IReadO } /// Same shape as BuildMeshBuilder, but each vertex also carries up to 4 (joint, - /// weight) bindings (glTF's JOINTS_0/WEIGHTS_0) instead of no skinning data at all — used when + /// weight) bindings (glTF's JOINTS_0/WEIGHTS_0) instead of no skinning data at all - used when /// the owning IMoby has a Skeleton. A mesh/vertex with no skin data of its own (GetJointIndices /// null, or an all-zero-weight vertex) falls back to a full-weight binding on the skeleton's /// root bone, so it still renders exactly at its authored position in the bind pose rather than @@ -272,14 +272,14 @@ private static VertexJoints4 BuildJoints(int[]? jointIndices, float[]? jointWeig return new VertexJoints4([.. bindings]); } - // No skin data for this vertex (rigid/unweighted part of an otherwise-skinned asset) — + // No skin data for this vertex (rigid/unweighted part of an otherwise-skinned asset) - // bind fully to the root so it still sits at its authored position in the bind pose. return new VertexJoints4(rootBoneIndex); } /// /// One NodeBuilder per bone, parented to mirror the skeleton hierarchy, with each node's - /// LocalTransform set to that bone's transform relative to its parent — computed as + /// LocalTransform set to that bone's transform relative to its parent - computed as /// `bone.WorldBindPose * parent.InverseBindPose`. An earlier version had this transliterated /// from InsomniaToolset's GenerateSkeleton (extract_gltf.cpp) with the operands reversed /// (`parent.InverseBindPose * bone.WorldBindPose`); these matrices are the row-vector @@ -287,11 +287,11 @@ private static VertexJoints4 BuildJoints(int[]? jointIndices, float[]? jointWeig /// RegionReader's identical sequential-float fill, and that other consumers of these same /// matrices Decompose them correctly elsewhere), so composing local-then-parent transforms /// for a row vector (`v' = v * Local * ParentWorld`) means the correct parent-relative - /// transform is `WorldBindPose * ParentInverseBindPose`, not the reverse — the reversed order + /// transform is `WorldBindPose * ParentInverseBindPose`, not the reverse - the reversed order /// silently produced a conjugated (wrong) rotation for any bone whose orientation doesn't /// commute with its parent's, deforming/exploding the exported mesh without any error. /// Returned in skeleton bone-index order so glTF's JOINTS_0 vertex indices (already resolved - /// to skeleton-global bone indices at read time — see MobyReader.ExtractSkinData) can be used + /// to skeleton-global bone indices at read time - see MobyReader.ExtractSkinData) can be used /// directly as indices into this array with no further remapping. /// private static NodeBuilder[] BuildJointNodes(ISkeleton skeleton, NodeBuilder? rootParent = null) @@ -322,7 +322,7 @@ void CreateNode(int index, NodeBuilder? parent) /// Builds a fresh joint hierarchy plus its glTF skin bindings (joint node, inverse /// bind matrix). A skinned mesh is positioned in the scene by its joint nodes' world /// transforms rather than by a rigid mesh-attach transform, so every placed instance of a - /// skinned asset needs its own joint hierarchy — pass `rootParent` (an instance's own + /// skinned asset needs its own joint hierarchy - pass `rootParent` (an instance's own /// transform node) so multiple instances of the same skeleton don't collapse onto the same /// placement. Only the mesh/skin-weight data (built separately) is safe to share across /// instances. @@ -332,7 +332,7 @@ internal static (NodeBuilder Node, Matrix4x4 InverseBindMatrix)[] BuildSkinnedJo return joints.Select((node, i) => (node, EnsureAffine(skeleton.Bones[i].InverseBindPose))).ToArray(); } - /// Zeroes the W column of the top 3 rows and forces M44=1 — cheap defensive cleanup + /// Zeroes the W column of the top 3 rows and forces M44=1 - cheap defensive cleanup /// against non-affine drift in source matrices, mirrored from the same cleanup InsomniaToolset /// applies before every Decompose/AffineTransform use of these bind-pose matrices. private static Matrix4x4 EnsureAffine(Matrix4x4 m) @@ -368,7 +368,7 @@ private static MaterialBuilder GetOrBuildMaterial(IMaterial material, Dictionary if (material.NormalTexture != null) { // Not a plain format pass-through: this game's normal maps store partial derivatives - // (dx=-nx/nz, dy=-ny/nz), not standard tangent-space (nx,ny,nz) values — see + // (dx=-nx/nz, dy=-ny/nz), not standard tangent-space (nx,ny,nz) values - see // TextureUtils.ReconstructNormalMap for the reconstruction and why it only applies // here, not to the live renderer's own GPU texture upload (AssetManager). var normalRgba = TextureUtils.ReconstructNormalMap(material.NormalTexture, out int normalWidth, out int normalHeight); @@ -383,7 +383,7 @@ private static MaterialBuilder GetOrBuildMaterial(IMaterial material, Dictionary { RenderMode.AlphaClip => AlphaMode.MASK, RenderMode.AlphaBlend => AlphaMode.BLEND, - // glTF has no additive alpha mode — BLEND is the closest approximation available; + // glTF has no additive alpha mode - BLEND is the closest approximation available; // falling through to OPAQUE here would export additive-glow materials as solid quads. RenderMode.Additive => AlphaMode.BLEND, _ => AlphaMode.OPAQUE, @@ -396,12 +396,12 @@ private static MaterialBuilder GetOrBuildMaterial(IMaterial material, Dictionary /// /// The "expensive"/properties texture packs specular (R), metallic (G) and emissive intensity - /// (B) into one image — not a layout any glTF texture slot accepts directly, so each channel + /// (B) into one image - not a layout any glTF texture slot accepts directly, so each channel /// gets split out into its own properly-shaped image: metallic into a synthesized /// metallicRoughnessTexture (metallic in B per glTF convention; no source roughness data, so G /// is filled with a constant mid-value), specular into KHR_materials_specular's - /// specularTexture (strength in A), and emissive — the B channel is only ever an *intensity*, - /// the actual glow color is the material's own albedo — into an RGB texture built by scaling + /// specularTexture (strength in A), and emissive - the B channel is only ever an *intensity*, + /// the actual glow color is the material's own albedo - into an RGB texture built by scaling /// each albedo texel by its co-located intensity texel (nearest-neighbor if the two textures /// aren't the same resolution). /// @@ -429,7 +429,7 @@ private static void ApplyExpensiveChannels(ITexture? propertiesTexture, byte[]? byte emissiveIntensity = rgba[i + 2]; metallicRoughness[i + 0] = 0; - metallicRoughness[i + 1] = 128; // no source roughness data — constant mid-value fallback + metallicRoughness[i + 1] = 128; // no source roughness data - constant mid-value fallback metallicRoughness[i + 2] = metallicValue; metallicRoughness[i + 3] = 255; @@ -457,9 +457,9 @@ private static void ApplyExpensiveChannels(ITexture? propertiesTexture, byte[]? builder.WithMetallicRoughness(NamedImage(TextureEncoding.EncodeRgbaToPng(metallicRoughness, width, height), texName, "_mr"), metallic: null, roughness: null); builder.WithSpecularFactor(NamedImage(TextureEncoding.EncodeRgbaToPng(specular, width, height), texName, "_spec"), 1.0f); // rgb must be an explicit Vector3.One, not null: MaterialBuilder.WithEmissive(image, rgb: - // null, ...) never calls the rgb-factor overload at all (see its source — it's guarded by + // null, ...) never calls the rgb-factor overload at all (see its source - it's guarded by // `if (rgb.HasValue)`), so glTF's emissiveFactor is left at its spec default of (0,0,0). - // That means finalEmissive = emissiveTexture * emissiveFactor = emissiveTexture * 0 — the + // That means finalEmissive = emissiveTexture * emissiveFactor = emissiveTexture * 0 - the // baked albedo-times-intensity texture below was correct but had zero visible effect in // the actual exported file. Verified empirically (decompiled + reproduced with a synthetic // export/reload round-trip) before fixing, not assumed from the method signature. @@ -467,11 +467,11 @@ private static void ApplyExpensiveChannels(ITexture? propertiesTexture, byte[]? } /// Wraps raw PNG bytes in an ImageBuilder with an explicit name/write-filename when - /// is given (ExportGltfSeparate's per-material texture naming — + /// is given (ExportGltfSeparate's per-material texture naming - /// see AssignTextureNames), otherwise returns the bytes as-is and lets the implicit byte[] to /// ImageBuilder conversion auto-name it (the .glb path, where the name is never user-visible). /// AlternateWriteFileName (not Name) is what SharpGLTF's satellite-file writer actually reads - /// for the on-disk filename — confirmed via decompile (Schema2.Image._WriteToSatellite) rather + /// for the on-disk filename - confirmed via decompile (Schema2.Image._WriteToSatellite) rather /// than assumed from the property name alone. The ".*" suffix defers the real extension (always /// ".png" here, from TextureEncoding.EncodeRgbaToPng, but this doesn't hardcode that) to /// SharpGLTF itself. diff --git a/ReLunacy.Engine/Export/LevelExporter.cs b/ReLunacy.Engine/Export/LevelExporter.cs index da3f207..021ae84 100644 --- a/ReLunacy.Engine/Export/LevelExporter.cs +++ b/ReLunacy.Engine/Export/LevelExporter.cs @@ -13,7 +13,7 @@ namespace ReLunacy.Engine.Export; /// /// Exports an entire loaded level as a single .glb. Unlike single-asset export, this builds each -/// unique Moby/Tie asset's mesh exactly once and references it from every placed instance's node — +/// unique Moby/Tie asset's mesh exactly once and references it from every placed instance's node - /// true glTF mesh instancing, so a level with hundreds of copies of the same prop doesn't duplicate /// its geometry hundreds of times, and Blender/Unreal show them as linked duplicates (edit one, /// every instance updates). Nodes are organized as Mobys/AssetName/Instance and @@ -53,13 +53,13 @@ public static void Export(string filePath, string levelName, EntityManager entit foreach (var instance in assetGroup) { // Mobys are always exported as static (rigid) meshes at whole-level scope, even - // when their asset has a skeleton — a shared skeletal asset placed more than once + // when their asset has a skeleton - a shared skeletal asset placed more than once // would need one fresh joint hierarchy per instance, all parented under the same // level-wide root, and SharpGLTF's armature validation rejects that as soon as two // instances' bone nodes collide by name (NodeBuilder.IsValidArmature walks the // whole scene graph under the shared root, not just one instance's joints), // throwing "Export failed: (Parameter 'joints')" on any level with a skinned Moby - // placed more than once. Single-asset export (AssetViewer) is unaffected — each + // placed more than once. Single-asset export (AssetViewer) is unaffected - each // export there gets its own standalone scene/root. AddInstanceNode(sceneBuilder, assetNode, instance.Name, instance.Transform.GetMatrix(), assetMeshes); anyContentAdded = true; @@ -122,14 +122,14 @@ public static void Export(string filePath, string levelName, EntityManager entit } if (!anyContentAdded) - throw new InvalidOperationException("Nothing to export — no assets found for the selected categories."); + throw new InvalidOperationException("Nothing to export - no assets found for the selected categories."); var model = sceneBuilder.ToGltf2(); model.SaveGLB(filePath); } /// Creates the instance's own transform node under its asset-type group, then attaches - /// the (possibly bangle-split) shared meshes — directly if there's only one, or as one child + /// the (possibly bangle-split) shared meshes - directly if there's only one, or as one child /// node per bangle if there's more, matching single-asset export's submesh grouping. private static void AddInstanceNode(SceneBuilder sceneBuilder, NodeBuilder assetNode, string instanceName, Matrix4x4 worldMatrix, IReadOnlyList<(string Name, IMeshBuilder Mesh)> assetMeshes) { @@ -152,7 +152,7 @@ private static void AddInstanceNode(SceneBuilder sceneBuilder, NodeBuilder asset if (cache.TryGetValue(moby.Id, out var cached)) return cached; - // Always the rigid (unskinned) builder — see the comment at this method's call site for why + // Always the rigid (unskinned) builder - see the comment at this method's call site for why // whole-level export never uses skeletal data, even for Mobys that have one. var result = moby.Bangles .Select((bangle, i) => string.IsNullOrEmpty(bangle.Name) ? $"Bangle_{i}" : bangle.Name) @@ -178,7 +178,7 @@ private static void AddInstanceNode(SceneBuilder sceneBuilder, NodeBuilder asset /// Adapts IUFrag (which carries geometry+material directly, not split into /// IMesh/IGeometry like Mobys/Ties) so GltfExporter.BuildMeshBuilder can build UFrag terrain - /// through the exact same code path — including the "expensive" texture channel mapping. + /// through the exact same code path - including the "expensive" texture channel mapping. private sealed class UFragMeshAdapter(IUFrag ufrag, string name) : IMesh, IGeometry { public IGeometry Geometry => this; @@ -193,8 +193,9 @@ private sealed class UFragMeshAdapter(IUFrag ufrag, string name) : IMesh, IGeome public float[] GetVertexPositions() => ufrag.GetVertexPositions(); public float[] GetTextureCoordinates() => ufrag.GetTextureCoordinates(); public float[]? GetNormals() => ufrag.GetNormals(); + public float[]? GetLightmapUVs() => ufrag.GetLightmapUVs(); - // UFrag terrain carries no baked tangent (or, on some readers, even normal) data — derive + // UFrag terrain carries no baked tangent (or, on some readers, even normal) data - derive // both from the triangle/UV data itself via the same fallback GeometryData uses for // formats that don't decode real vertex attributes. public float[]? GetTangents() diff --git a/ReLunacy.Engine/Export/MeshGroup.cs b/ReLunacy.Engine/Export/MeshGroup.cs index f09a5cc..dd64342 100644 --- a/ReLunacy.Engine/Export/MeshGroup.cs +++ b/ReLunacy.Engine/Export/MeshGroup.cs @@ -2,6 +2,6 @@ namespace ReLunacy.Engine.Export; -/// A named group of meshes that should stay a distinct submesh/node on export — a Moby's +/// A named group of meshes that should stay a distinct submesh/node on export - a Moby's /// bangle, or (for assets with no such grouping, e.g. Ties) the whole model as a single group. public readonly record struct MeshGroup(string Name, IReadOnlyList Meshes); diff --git a/ReLunacy.Engine/Export/ObjExporter.cs b/ReLunacy.Engine/Export/ObjExporter.cs index 4ac0fe2..0268236 100644 --- a/ReLunacy.Engine/Export/ObjExporter.cs +++ b/ReLunacy.Engine/Export/ObjExporter.cs @@ -5,14 +5,14 @@ namespace ReLunacy.Engine.Export; /// /// Exports engine mesh groups (a Moby's bangles, or a Tie's single whole-model group) as -/// Wavefront OBJ + MTL + loose PNG textures. Only albedo/normal are written — MTL has no +/// Wavefront OBJ + MTL + loose PNG textures. Only albedo/normal are written - MTL has no /// standard slot for the specular/metallic/emissive data packed into the "expensive" texture /// (see GltfExporter, which carries all of it via glTF's PBR extensions instead). /// public static class ObjExporter { /// `skeleton` is accepted (and ignored) only so this matches GltfExporter.Export's - /// signature — the two are called through the same delegate type in AssetViewer.ExportModel. + /// signature - the two are called through the same delegate type in AssetViewer.ExportModel. /// OBJ/MTL has no representation for a bone hierarchy or vertex skin weights at all. public static void Export(string filePath, string modelName, IReadOnlyList groups, ISkeleton? skeleton = null, Action? onProgress = null) { @@ -48,7 +48,7 @@ public static void Export(string filePath, string modelName, IReadOnlyList1 means this asset has real submesh groups (a Moby's bangles) — keep + // Groups.Count>1 means this asset has real submesh groups (a Moby's bangles) - keep // that grouping visible in the object name rather than flattening it away. obj.AppendLine(groups.Count > 1 && !string.IsNullOrEmpty(group.Name) ? $"o {group.Name}_{meshLabel}" @@ -57,8 +57,11 @@ public static void Export(string filePath, string modelName, IReadOnlyList= vertexCount * 3; if (hasNormals) diff --git a/ReLunacy.Engine/Export/TextureEncoding.cs b/ReLunacy.Engine/Export/TextureEncoding.cs index c0c6e5a..a1f9581 100644 --- a/ReLunacy.Engine/Export/TextureEncoding.cs +++ b/ReLunacy.Engine/Export/TextureEncoding.cs @@ -1,13 +1,12 @@ -using Bliss.CSharp.Images; using ReLunacy.Engine.Assets.Interfaces; using ReLunacy.Engine.Rendering; +using ReLunacy.Engine.Rendering.Resources; namespace ReLunacy.Engine.Export; /// -/// Shared PNG-encoding helpers for the model exporters (glTF embeds PNG bytes directly, OBJ -/// writes them as loose sibling files). Bliss's Image only exposes SaveAsPng(path), so encoding -/// to an in-memory byte[] round-trips through a temp file rather than reimplementing a PNG encoder. +/// Shared PNG-encoding helpers for the model exporters (glTF embeds PNG bytes directly, OBJ writes +/// them as loose sibling files). /// public static class TextureEncoding { @@ -20,16 +19,8 @@ public static class TextureEncoding public static byte[] EncodeRgbaToPng(byte[] rgba, int width, int height) { - var image = new Image(width, height, rgba); - string tempPath = Path.Combine(Path.GetTempPath(), $"relunacy_export_{Guid.NewGuid():N}.png"); - try - { - image.SaveAsPng(tempPath); - return File.ReadAllBytes(tempPath); - } - finally - { - File.Delete(tempPath); - } + // This used to write a temp file and read it straight back, because the encoder behind the + // old Image type was only reachable through a path. + return new Image(width, height, rgba).EncodeToPng(); } } diff --git a/ReLunacy.Engine/Games/GameDefinition.cs b/ReLunacy.Engine/Games/GameDefinition.cs index 27dcbf9..ba05512 100644 --- a/ReLunacy.Engine/Games/GameDefinition.cs +++ b/ReLunacy.Engine/Games/GameDefinition.cs @@ -2,6 +2,7 @@ namespace ReLunacy.Engine.Games; public enum GameId { + RFallOfMan, ToolsOfDestruction, QuestForBooty, ACrackInTime, @@ -12,19 +13,18 @@ public enum GameId public sealed record GameDefinition(GameId Id, string DisplayName, bool IsOldEngine, IReadOnlyList KnownLevels); -// Level names are intentionally empty for now — fill in KnownLevels per game to enable -// GameLibraryScanner's game-identification match. Detection/browsing itself works without -// them (any folder or archive that looks like a level is still found), it just can't yet -// tell you WHICH of the 6 games a USRDIR belongs to. +// TODO: Add all Ratchet & Clank level names +// TODO: Add Resistance games and their levels public static class GameDefinitions { public static readonly IReadOnlyList All = [ - new(GameId.ToolsOfDestruction, "Ratchet & Clank: Tools of Destruction", IsOldEngine: true, KnownLevels: []), + new(GameId.ToolsOfDestruction, "Ratchet & Clank: Tools of Destruction", IsOldEngine: true, KnownLevels: ["apogee space station", "cobalia", "cragmite ruins", "fastoon", "fastoon_return", "imperial fight fest", "iris", "kerchu city", "level_transitions", "meridian city", "metropolis", "pirate base", "rykan v", "sargasso", "slags_fleet", "space combat i", "space combat ii", "space combat iii", "stratus city", "zordoom prison"]), new(GameId.QuestForBooty, "Ratchet & Clank: Quest for Booty", IsOldEngine: true, KnownLevels: ["level_transitions", "npc_island", "prologue", "treasure_island", "viper_caverns"]), new(GameId.ACrackInTime, "Ratchet & Clank: A Crack in Time", IsOldEngine: false, KnownLevels: []), new(GameId.FullFrontalAssault, "Ratchet & Clank: Full Frontal Assault", IsOldEngine: false, KnownLevels: []), new(GameId.All4One, "Ratchet & Clank: All 4 One", IsOldEngine: false, KnownLevels: []), new(GameId.IntoTheNexus, "Ratchet & Clank: Into the Nexus", IsOldEngine: false, KnownLevels: []), + new(GameId.RFallOfMan, "Resistance Fall Of Man", IsOldEngine: true, KnownLevels: ["level20", "level21", "level22", "level30", "level31", "level32", "level40", "level41", "level42", "level50", "level51", "level52"]) ]; } diff --git a/ReLunacy.Engine/Games/GameLibraryScanner.cs b/ReLunacy.Engine/Games/GameLibraryScanner.cs index cfd5ea9..899ce93 100644 --- a/ReLunacy.Engine/Games/GameLibraryScanner.cs +++ b/ReLunacy.Engine/Games/GameLibraryScanner.cs @@ -10,7 +10,7 @@ public sealed record GameLibrary(GameDefinition? DetectedGame, string RootPath, /// layout up front: old-engine games keep extracted level folders under packed/levels/<name>, /// new-engine games (and repacked old-engine dumps) may ship the same data as .psarc archives /// anywhere under the root. A folder/archive only counts as a level if it actually contains -/// main.dat (old engine) or gameplay.dat (new engine) — not just because it has a plausible name. +/// main.dat (old engine) or gameplay.dat (new engine) - not just because it has a plausible name. /// Game identification itself depends on GameDefinition.KnownLevels being populated; until then /// this still finds and lists every level, it just can't say which of the 6 games they belong to. /// @@ -65,7 +65,7 @@ private static IEnumerable ScanPsarcLevels(string rootPath) continue; // The archive's own filename is always one of a fixed handful (level_cached, - // level_uncached, level_textures) — the actual level name is the folder it lives in + // level_uncached, level_textures) - the actual level name is the folder it lives in // (packed/levels//level_cached.psarc), which is also what GameDefinition's // KnownLevels lists match against. string levelName = Path.GetFileName(Path.GetDirectoryName(psarcPath)) ?? Path.GetFileNameWithoutExtension(psarcPath); @@ -74,7 +74,7 @@ private static IEnumerable ScanPsarcLevels(string rootPath) } /// - /// Old engine only: debug.dat almost never lives inside the level's own main.dat/psarc — it's + /// Old engine only: debug.dat almost never lives inside the level's own main.dat/psarc - it's /// a loose file at <root>/built/levels/<name>/debug.dat, next to (but not part of) the /// packed level data under packed/levels/<name>. /// @@ -89,7 +89,7 @@ private static IEnumerable ScanPsarcLevels(string rootPath) /// something like Program.ProvidedPath and never went through (so never got /// a proper .Name). Same rule as : a .psarc's own /// filename is always one of a fixed handful (level_cached, level_uncached, level_textures), so - /// for a file path the actual level name is its containing folder, not the file itself — a + /// for a file path the actual level name is its containing folder, not the file itself - a /// folder path (old engine, or a pre-extracted new-engine level) is already the level name. /// public static string GetLevelNameFromPath(string path) @@ -101,7 +101,7 @@ public static string GetLevelNameFromPath(string path) /// /// Same lookup as , but starting from a level's own folder or - /// .psarc file path instead of an already-known root — for callers (manual "Open level" file + /// .psarc file path instead of an already-known root - for callers (manual "Open level" file /// pickers) that never went through and so never got a root path at all. /// Only works when the path still matches the standard packed/levels/<name> nesting. /// diff --git a/ReLunacy.Engine/Games/Level.cs b/ReLunacy.Engine/Games/Level.cs index be25bb8..c6b668f 100644 --- a/ReLunacy.Engine/Games/Level.cs +++ b/ReLunacy.Engine/Games/Level.cs @@ -27,7 +27,7 @@ public sealed class Level : IDisposable /// /// Old engine only: resolved location of this level's debug.dat, if one was found near it at - /// scan time (see GameLibraryScanner.ResolveDebugDatPath) — it never ships inside main.dat/the + /// scan time (see GameLibraryScanner.ResolveDebugDatPath) - it never ships inside main.dat/the /// level's own .psarc, so this has to be tracked separately. /// public string? DebugDatPath { get; } diff --git a/ReLunacy.Engine/Loading/IO/FileManager.cs b/ReLunacy.Engine/Loading/IO/FileManager.cs index 527d689..e711031 100644 --- a/ReLunacy.Engine/Loading/IO/FileManager.cs +++ b/ReLunacy.Engine/Loading/IO/FileManager.cs @@ -10,10 +10,10 @@ namespace ReLunacy.Engine.Loading.IO; public class FileManager : IDisposable { public string folderPath = string.Empty; - // New engine levels split their data across two sibling archives — level_cached.psarc (the - // one GameLibraryScanner finds, holding gameplay.dat/assetlookup.dat/mobys.dat/etc.) and - // level_uncached.psarc (holding highmips.dat and streaming audio) — so a single archive - // reference isn't enough to resolve every file. Old engine only ever uses one. + // Both engines split a level's data across sibling archives next to the one GameLibraryScanner + // finds (level_cached.psarc) - new engine keeps highmips.dat and streaming audio in + // level_uncached.psarc, old engine keeps texstream.dat in level_textures.psarc - so a single + // archive reference isn't enough to resolve every file for either engine. private readonly List _archives = []; public Dictionary igfiles = []; @@ -39,12 +39,14 @@ public void LoadFromPsarc(PSARC archive) } /// - /// Opens a level directly from its own .psarc path and, if it looks like a new-engine level - /// (no main.dat), also picks up the sibling level_uncached.psarc next to it — new engine keeps - /// highmips.dat (and streaming audio) there instead of in the level's main archive, so without - /// this, loading a new-engine level straight from a .psarc throws once texture loading reaches - /// highmips.dat. Old engine keeps everything in one archive, so this is a no-op for it beyond - /// opening the given path. + /// Opens a level directly from its own .psarc path and also picks up every other level_*.psarc + /// sibling next to it - new engine keeps highmips.dat (and streaming audio) in + /// level_uncached.psarc instead of the level's main archive, and old engine keeps texstream.dat + /// in level_textures.psarc instead of textures.dat's archive, so without this, texture loading + /// for either engine silently misses whichever file its engine split out. Discovered by name + /// rather than hardcoded to one sibling, so it does not need to know every archive an engine + /// might split off, and stays correct if a level has no siblings at all (the common case for a + /// level with everything in one archive is then a no-op beyond opening the given path). /// public void LoadFromPsarcFile(string path) { @@ -53,14 +55,15 @@ public void LoadFromPsarcFile(string path) _archives.Add(primary); isOld = ArchiveContains(primary, "main.dat"); - if (!isOld) + string? dir = Path.GetDirectoryName(path); + string fullPrimaryPath = Path.GetFullPath(path); + if (dir != null) { - string? dir = Path.GetDirectoryName(path); - if (dir != null && string.Equals(Path.GetFileName(path), "level_cached.psarc", StringComparison.OrdinalIgnoreCase)) + foreach (string siblingPath in Directory.EnumerateFiles(dir, "level_*.psarc")) { - string siblingPath = Path.Combine(dir, "level_uncached.psarc"); - if (File.Exists(siblingPath)) - _archives.Add(new PSARC(File.OpenRead(siblingPath))); + if (string.Equals(Path.GetFullPath(siblingPath), fullPrimaryPath, StringComparison.OrdinalIgnoreCase)) + continue; + _archives.Add(new PSARC(File.OpenRead(siblingPath))); } } @@ -112,7 +115,7 @@ private void LoadFixedFileSet() /// /// Old engine only: debug.dat almost never ships alongside main.dat in the level's own - /// folder/archive — it's a loose file elsewhere (see GameLibraryScanner.TryResolveDebugDatPath). + /// folder/archive - it's a loose file elsewhere (see GameLibraryScanner.TryResolveDebugDatPath). /// Loads it directly from an explicit path, overwriting any prior (likely missing) entry. /// public bool LoadExternalDebugDat(string path) @@ -164,7 +167,7 @@ public bool LoadExternalDebugDat(string path) } /// - /// Closes every open handle this FileManager holds — each entry in igfiles/rawfiles wraps its + /// Closes every open handle this FileManager holds - each entry in igfiles/rawfiles wraps its /// own FileStream (or, for a .psarc source, the archive's own FileStream), none of which were /// ever closed on level unload previously. Without this, switching levels repeatedly leaks a /// file handle per .dat file per switch. diff --git a/ReLunacy.Engine/Loading/IO/StreamHelper.cs b/ReLunacy.Engine/Loading/IO/StreamHelper.cs index 5907fa7..0434177 100644 --- a/ReLunacy.Engine/Loading/IO/StreamHelper.cs +++ b/ReLunacy.Engine/Loading/IO/StreamHelper.cs @@ -6,7 +6,7 @@ namespace ReLunacy.Engine.Loading.IO; // Endianness-aware BinaryReader. Per-file endianness is auto-detected from the IGHW magic -// (see IGFile), not fixed — the game ships both big- and little-endian containers. +// (see IGFile), not fixed - the game ships both big- and little-endian containers. public class StreamHelper : BinaryReader { public enum Endianness @@ -266,7 +266,7 @@ public byte[] ReadForEndianness(int bytesToRead, Endianness endianness) return bytesRead; } - // Write methods (endianness-aware) — used by the round-trip/rebuild path (AssetBuilder). + // Write methods (endianness-aware) - used by the round-trip/rebuild path (AssetBuilder). public void WriteUInt16(ushort value) { diff --git a/ReLunacy.Engine/Loading/Interfaces/IMobyInstance.cs b/ReLunacy.Engine/Loading/Interfaces/IMobyInstance.cs index 04b0059..50268d2 100644 --- a/ReLunacy.Engine/Loading/Interfaces/IMobyInstance.cs +++ b/ReLunacy.Engine/Loading/Interfaces/IMobyInstance.cs @@ -10,4 +10,6 @@ public interface IMobyInstance public ushort MobyIndex { get; set; } /// Distance (in-game units) beyond which the game itself stops rendering this instance. Raw file value; <= 0 means unlimited. public float DisplayDistance { get; set; } + /// Distance (in-game units) beyond which the game stops updating this instance's logic. Raw file value; <= 0 means unlimited. + public float UpdateDistance { get; set; } } diff --git a/ReLunacy.Engine/Loading/Interfaces/ITextureMetadata.cs b/ReLunacy.Engine/Loading/Interfaces/ITextureMetadata.cs index 67ec625..edac3b8 100644 --- a/ReLunacy.Engine/Loading/Interfaces/ITextureMetadata.cs +++ b/ReLunacy.Engine/Loading/Interfaces/ITextureMetadata.cs @@ -12,6 +12,6 @@ public interface ITextureMetadata /// True if this texture's pixel data is stored linearly (not Morton-swizzled). Always /// true for block-compressed formats (DXT/BC) regardless of any per-instance flag. Otherwise /// per-instance: old engine reads it from a bit in formatBitfield, new engine derives it from - /// the raw format byte's 0x8X (swizzled) / 0xAX (linear) prefix — see TextureMetadataOld/New. + /// the raw format byte's 0x8X (swizzled) / 0xAX (linear) prefix - see TextureMetadataOld/New. public bool IsLinear { get; } } diff --git a/ReLunacy.Engine/Loading/Meshes/MobyMesh.cs b/ReLunacy.Engine/Loading/Meshes/MobyMesh.cs index 191c40c..5936ac6 100644 --- a/ReLunacy.Engine/Loading/Meshes/MobyMesh.cs +++ b/ReLunacy.Engine/Loading/Meshes/MobyMesh.cs @@ -14,7 +14,7 @@ public record struct MobyMesh : ILunaSerializable, IMesh public const uint Size = 0x40; // New engine only: unlike ties/ufrags, moby vertex/index buffers aren't a raw-file offset - // field on NewMoby — they're their own sections inside the moby's own per-record IGFile. + // field on NewMoby - they're their own sections inside the moby's own per-record IGFile. public const uint VerticesSecID = 0xE200, IndicesSecID = 0xE100; [FileOffset(0x00)] public uint indicesOffset; @@ -45,7 +45,7 @@ public record struct MobyMesh : ILunaSerializable, IMesh public ushort[] indices; /// - /// This primitive's local joint palette — vertex bone indices (VertexFormat1.bones, + /// This primitive's local joint palette - vertex bone indices (VertexFormat1.bones, /// VertexFormat0.boneIndex) are local indices into THIS array, not skeleton-global bone /// indices directly (confirmed against InsomniaToolset's PrimitiveV2.joints / the /// AttributeBoneIndex(indices) codecs in its glTF exporter). Empty for meshes with no skin @@ -159,7 +159,7 @@ public void ReadIndicesBuffer(StreamHelper sh) /// /// Reads this primitive's joint palette (boneMapIndicesCount uint16 entries at boneMapOffset) - /// — same absolute-from-mobyStream-start pointer convention already proven by the bangle/mesh + /// - same absolute-from-mobyStream-start pointer convention already proven by the bangle/mesh /// [Reference] chain and by MobySkeletonReader, so no per-engine adjustment is needed. `sh` /// must be the moby's own mobyStream, not verticesStream/indicesStream (boneMapOffset is a /// header field resolved the same way skeletonPointer/banglesPointer are, not a bulk-buffer @@ -181,7 +181,7 @@ public void ReadBoneMap(StreamHelper sh) sh.Seek(savedPosition); } - public readonly void GetBuffers(float scalar, out float[] vpos, out uint[] ind, out float[] uvcoords, out float[] normals, out float[] tangents, out float[] vertexAlphaCandidates) + public readonly void GetBuffers(float scalar, out float[] vpos, out uint[] ind, out float[] uvcoords, out float[] normals, out float[] tangents) { ind = new uint[indicesCount]; for (int k = 0; k < indicesCount; k++) ind[k] = indices[k]; @@ -190,12 +190,11 @@ public readonly void GetBuffers(float scalar, out float[] vpos, out uint[] ind, uvcoords = new float[verticesCount * 2]; normals = new float[verticesCount * 3]; tangents = new float[verticesCount * 3]; - vertexAlphaCandidates = new float[verticesCount]; for (int k = 0; k < verticesCount; k++) { // Mobys scale uniformly (single scalar, unlike Ties' per-axis Vector3), so neither a - // decoded normal nor tangent needs any axis-dependent correction — direction is + // decoded normal nor tangent needs any axis-dependent correction - direction is // unaffected by uniform scale, only renormalized since the packed decode isn't exactly // unit length. Vector3 n, t; @@ -208,7 +207,6 @@ public readonly void GetBuffers(float scalar, out float[] vpos, out uint[] ind, uvcoords[k * 2 + 1] = (float)vertices0[k].UVs.Item2; n = vertices0[k].Normal; t = vertices0[k].Tangent; - vertexAlphaCandidates[k] = vertices0[k].VertexAlphaCandidate; } else { @@ -219,10 +217,6 @@ public readonly void GetBuffers(float scalar, out float[] vpos, out uint[] ind, uvcoords[k * 2 + 1] = (float)vertices1[k].UVs.Item2; n = vertices1[k].Normal; t = vertices1[k].Tangent; - // VertexFormat1's Unk1 is the skinned equivalent of VertexFormat0.boneIndex, but - // unlike boneIndex it's confirmed to carry tangible (bone-related) data on boned - // meshes — not a vertex alpha candidate, so no decode applies here. - vertexAlphaCandidates[k] = 1f; } n = n.LengthSquared() > 1e-12f ? Vector3.Normalize(n) : Vector3.UnitY; diff --git a/ReLunacy.Engine/Loading/Meshes/TieMesh.cs b/ReLunacy.Engine/Loading/Meshes/TieMesh.cs index e5e866a..9c26ce8 100644 --- a/ReLunacy.Engine/Loading/Meshes/TieMesh.cs +++ b/ReLunacy.Engine/Loading/Meshes/TieMesh.cs @@ -14,10 +14,10 @@ public record struct TieMesh : ILunaSerializable, IMesh [FileOffset(0x00)] public uint indicesIndex; // Matches the legacy reference (CTie.TieMesh) and this project's own old-engine manual - // override in Tie.cs — both read verticesIndex/verticesCount/indicesCount at 0x04/0x08/0x12 + // override in Tie.cs - both read verticesIndex/verticesCount/indicesCount at 0x04/0x08/0x12 // for BOTH engines. The previous 0x34/0x38/0x42 offsets here were wrong: 0x42+2 exceeds this // struct's own declared Size (0x40), so indicesCount was reading 2 bytes into the *next* - // record — explains the wildly-oversized indicesCount / vertexCount==0 seen on new-engine ties. + // record - explains the wildly-oversized indicesCount / vertexCount==0 seen on new-engine ties. [FileOffset(0x04)] public ushort verticesIndex; [FileOffset(0x06)] public ushort Unk1; [FileOffset(0x08)] public ushort verticesCount; @@ -106,7 +106,7 @@ public readonly void GetBuffers(Vector3 scale, out float[] vpos, out uint[] ind, uvcoords[k * 2 + 0] = (float)vertices[k].UVs.Item1; uvcoords[k * 2 + 1] = (float)vertices[k].UVs.Item2; - // Ties can have non-uniform per-axis scale (unlike Mobys' single scalar) — a normal + // Ties can have non-uniform per-axis scale (unlike Mobys' single scalar) - a normal // under non-uniform scale must use the inverse-transpose (divide by the same per-axis // scale applied to positions, then renormalize), not be scaled like a position, or // lighting skews on any Tie that isn't scaled equally on all three axes. @@ -118,7 +118,7 @@ public readonly void GetBuffers(Vector3 scale, out float[] vpos, out uint[] ind, normals[k * 3 + 2] = scaledN.Z; // Unlike the normal, a tangent lies IN the surface (it's an edge/gradient direction, - // not a perpendicular) — under non-uniform scale it transforms with the scale + // not a perpendicular) - under non-uniform scale it transforms with the scale // directly, the same as a position, not with the inverse-transpose. Vector3 t = vertices[k].Tangent; Vector3 scaledT = new(t.X * scale.X, t.Y * scale.Y, t.Z * scale.Z); diff --git a/ReLunacy.Engine/Loading/Objects/AssetPointer.cs b/ReLunacy.Engine/Loading/Objects/AssetPointer.cs index ae40ee2..68e1204 100644 --- a/ReLunacy.Engine/Loading/Objects/AssetPointer.cs +++ b/ReLunacy.Engine/Loading/Objects/AssetPointer.cs @@ -15,7 +15,7 @@ public AssetPointer(StreamHelper sh) { // sh.ReadUInt64(0x00) looks like "read at absolute offset 0" but isn't: the literal 0 // implicitly converts to StreamHelper.Endianness (whose first member is 0), so that call - // actually resolved to ReadUInt64(Endianness.Little) — wrong byte order (this format is + // actually resolved to ReadUInt64(Endianness.Little) - wrong byte order (this format is // big-endian throughout) and no seek at all. And offset/length's literal offsets (0x08, // 0x0C) are absolute from the stream's start, not relative to this record, so every // AssetPointer past the first in an array read from the same fixed two bytes regardless of @@ -26,7 +26,7 @@ public AssetPointer(StreamHelper sh) length = sh.ReadUInt32(); } - /// Reads `count` consecutive AssetPointer records starting at the stream's current position — the array-reading counterpart to the single-record constructor above, since AssetPointer's hand-written constructor isn't compatible with FileUtils.ReadStructureArray's [FileStructure]/[FileOffset] reflection. + /// Reads `count` consecutive AssetPointer records starting at the stream's current position - the array-reading counterpart to the single-record constructor above, since AssetPointer's hand-written constructor isn't compatible with FileUtils.ReadStructureArray's [FileStructure]/[FileOffset] reflection. public static AssetPointer[] ReadArray(StreamHelper sh, uint count) { var items = new AssetPointer[count]; diff --git a/ReLunacy.Engine/Loading/Objects/FoliageMetadata.cs b/ReLunacy.Engine/Loading/Objects/FoliageMetadata.cs new file mode 100644 index 0000000..c96d3e9 --- /dev/null +++ b/ReLunacy.Engine/Loading/Objects/FoliageMetadata.cs @@ -0,0 +1,160 @@ +using ReLunacy.Engine.Loading.IO; +using ReLunacy.Engine.Loading.Interfaces; +using ReLunacy.Engine.Loading.Vertices; + +namespace ReLunacy.Engine.Loading.Objects; + +/// A foliage ASSET - the card set for one kind of plant, instanced across the level by +/// . Old-engine section 0xA200, 176 bytes per record +/// (metropolis has exactly two). +/// +/// InsomniaToolset's Foliage (its ID 0xC200 is a different engine revision and does not appear in +/// this game's files) matches this record's first 32 bytes field for field, which is what pins the +/// layout: branchLods lands at 0x20 only if the preceding nine fields are exactly as that struct +/// declares them. It is confirmed by the data rather than by trust - the four branch LOD entries +/// hold CONSECUTIVE index ranges (462117 + 156 = 462273, + 84 = 462357), which a wrong alignment +/// would not produce. +/// +/// GEOMETRY OFFSETS POINT INTO vertices.dat SECTION 0x9000, not into main.dat. That is worth +/// stating because both files have a blob at a plausible address and reading them against main.dat +/// yields NaNs and values in the 1e38 range that look superficially like data. Against 0x9000 the +/// same bytes decode as clean sprite cards. +public record struct FoliageMetadata : ILunaSerializable +{ + public const uint ID = 0xA200; + public const uint Size = 0xB0; + + /// Number of sprite LOD ranges actually populated (metropolis: 5 of the 5 slots). + public const int MaxSpriteLods = 5; + + /// sentinel: this foliage asset binds no texture. The game + /// tests the field against -1 and takes a fallback branch instead of indexing 0x5200 (EBOOT + /// 0x4E2304 / 0x4E233C), so this must be resolved to "no texture", never used as an index. + public const uint NoTexture = 0xFFFFFFFF; + + /// 0x0F on both metropolis foliages. Not identified - a flag set, most likely. + public uint Unk0; + + /// u16 @ 0x04. InsomniaToolset names this foliageId. It is 0 on BOTH metropolis + /// foliages, so it is NOT the field that distinguishes the two assets - the earlier note here + /// claimed 0x04 was the varying field (0/0 then 1/1), which is wrong for this sample. The pair + /// that actually moves between the two assets is (0x06) and + /// (0x08), each 0 on the first and 1 on the second. + public ushort FoliageId; + + /// u16 @ 0x06. The renderer reads this directly off the live A200 pointer (EBOOT + /// 0x51FFD0 and 0x52007C both `lhz rN,0x06(...)`), so it is a genuine per-asset selection/sort + /// key rather than padding - but which exactly (material variant, render key, foliage type) is + /// not pinned down, so it keeps a neutral name. Varies 0/1 across the two metropolis assets, in + /// lockstep with . NOT needed to resolve the texture. + public ushort Unk6; + + /// DIRECT physical index into the old-engine texture table (section 0x5200, + /// ) - NOT a shader lookup. The game's own A200 loader + /// proves it instruction for instruction: it reads this field (EBOOT 0x4E22F8, `lwz r14,0x08`), + /// tests it against -1 (0x4E2304), and when it isn't the sentinel rewrites the slot in place as + /// `section5200Base + TextureIndex * 0x20` (0x4E22B8..0x4E22CC multiply the index by 0x20 and add + /// the section base the 0x5200 handler cached at manager+0x0C, EBOOT 0x4E2054). So the texture is + /// TextureMetadataOld[TextureIndex], addressed by POSITION in the table, not by id/TUID. + /// + /// This supersedes the earlier shader-626/627 inference, which was a guess from two samples and + /// is NOT what the loader does - there is no shader indirection and no 0/1 -> 1286/1287 remap. + /// The atlas bound for asset 0 is 0x5200 entry 0 (512x512 DXT5), for asset 1 entry 1; confirmed + /// independently by the two descriptors' pixel offsets sitting exactly one full 512x512 BC3 mip + /// chain (0x55580) apart. + /// + /// 0xFFFFFFFF is the "no texture" sentinel (see / ). + /// Resolve through TextureShaderLoader.ResolveOldTextureIndex, which handles both the sentinel + /// and an out-of-range index. + public uint TextureIndex; + + public uint Unk5; + public uint IndexOffset; + public uint Null0; + + /// Branch geometry, in vertices.dat section 0x9000. Branches are the non-billboard + /// part of a foliage asset (trunks/stems); NOT decoded yet - only the sprite cards are. + public uint BranchVertexOffset; + public uint Unk1; + + /// Four branch LODs, {indexOffset, numIndices, unk}. Ranges are consecutive. + public FoliageBranchLod[] BranchLods; + + /// Start of the per-corner array () in vertices.dat + /// section 0x9000. Length is * 8 bytes. + public uint SpriteCornerOffset; + + /// Start of the per-sprite array (), immediately + /// after the corner array. Length is (TotalCorners / 4) * 8 bytes. On metropolis the two arrays + /// tile exactly: 468*8 = 3744 bytes of corners then 117*8 = 936 bytes of anchors, ending + /// precisely where the next foliage asset's data begins. + public uint SpriteAnchorOffset; + + public uint UsedSpriteLods; + + /// Sprite LOD ranges in CORNER units, {cornerBegin, cornerEnd, distance}. Consecutive + /// and non-overlapping: metropolis gives [0..232) [232..352) [352..420) [420..464) [464..468), + /// i.e. 58, 30, 17, 11 and finally 1 card - a foliage LOD chain down to a single billboard. + public FoliageSpriteLodRange[] SpriteLodRanges; + + /// Total corners across every LOD = the last range's end. Divide by 4 for cards. + public readonly int TotalCorners => + SpriteLodRanges is { Length: > 0 } ? SpriteLodRanges[^1].CornerEnd : 0; + + public readonly int TotalSprites => TotalCorners / FoliageSpriteCorner.CornersPerSprite; + + /// False when is the sentinel, i.e. + /// the game would take its no-texture fallback for this asset. + public readonly bool HasTexture => TextureIndex != NoTexture; + + public static FoliageMetadata Read(StreamHelper sh, uint recordBase) + { + var m = new FoliageMetadata + { + Unk0 = sh.ReadUInt32(recordBase + 0x00), + FoliageId = sh.ReadUInt16(recordBase + 0x04), + Unk6 = sh.ReadUInt16(recordBase + 0x06), + TextureIndex = sh.ReadUInt32(recordBase + 0x08), + Unk5 = sh.ReadUInt32(recordBase + 0x0C), + IndexOffset = sh.ReadUInt32(recordBase + 0x10), + Null0 = sh.ReadUInt32(recordBase + 0x14), + BranchVertexOffset = sh.ReadUInt32(recordBase + 0x18), + Unk1 = sh.ReadUInt32(recordBase + 0x1C), + BranchLods = new FoliageBranchLod[4], + SpriteLodRanges = new FoliageSpriteLodRange[MaxSpriteLods], + }; + + for (int i = 0; i < 4; i++) + { + uint b = recordBase + 0x20 + (uint)i * 8; + m.BranchLods[i] = new FoliageBranchLod(sh.ReadUInt32(b), sh.ReadUInt16(b + 4), sh.ReadUInt16(b + 6)); + } + + m.SpriteCornerOffset = sh.ReadUInt32(recordBase + 0x40); + m.SpriteAnchorOffset = sh.ReadUInt32(recordBase + 0x44); + m.UsedSpriteLods = sh.ReadUInt32(recordBase + 0x48); + + for (int i = 0; i < MaxSpriteLods; i++) + { + uint b = recordBase + 0x50 + (uint)i * 8; + ushort begin = sh.ReadUInt16(b); + ushort end = sh.ReadUInt16(b + 2); + sh.Seek(b + 4); + m.SpriteLodRanges[i] = new FoliageSpriteLodRange(begin, end, sh.ReadSingle()); + } + + return m; + } + + public readonly byte[] ToBytes(bool isOld, params object[]? additionalParams) => throw new NotImplementedException(); +} + +/// One branch LOD: a range into the foliage index buffer. +public readonly record struct FoliageBranchLod(uint IndexOffset, ushort IndexCount, ushort Unk); + +/// One sprite LOD: a half-open range in CORNER units, plus its switch distance. +public readonly record struct FoliageSpriteLodRange(ushort CornerBegin, ushort CornerEnd, float Distance) +{ + public int CornerCount => CornerEnd - CornerBegin; + public int SpriteCount => CornerCount / Vertices.FoliageSpriteCorner.CornersPerSprite; +} diff --git a/ReLunacy.Engine/Loading/Objects/Instances/FoliageInstance.cs b/ReLunacy.Engine/Loading/Objects/Instances/FoliageInstance.cs new file mode 100644 index 0000000..75901e6 --- /dev/null +++ b/ReLunacy.Engine/Loading/Objects/Instances/FoliageInstance.cs @@ -0,0 +1,61 @@ +using System.Numerics; +using ReLunacy.Engine.Loading.IO; +using ReLunacy.Engine.Loading.Interfaces; + +namespace ReLunacy.Engine.Loading.Objects.Instances; + +/// One placement of a foliage asset. Old-engine section 0x9340, 224 bytes per record +/// (metropolis: 757 instances across 2 assets). +/// +/// The 224-byte size is exactly InsomniaToolset's FoliageInstance - Matrix44 (0x40) + float[33] +/// (0x84) + a foliage pointer + uint[2] + uint[4] - and the content agrees: the first 64 bytes +/// decode as an affine matrix (instance 0 translates to 340.49, 30.45, -250.64) and the field at +/// 0xC4 holds an absolute main.dat address that lands on a record of section 0xA200 for all 757. +/// The toolset's own ID for this (0x9700) does not appear in this game's files; 0x9340 is the +/// old-engine equivalent, matched by size and by that pointer resolving. +public record struct FoliageInstance : ILunaSerializable +{ + public const uint ID = 0x9340; + public const uint Size = 0xE0; + + /// Placement matrix, read raw. Decompose it directly rather than going through Euler + /// properties, for the same reason EntityTie does. + public Matrix4x4 Transform; + + /// Absolute offset of this placement's record inside + /// main.dat - not an index. Resolve it against section 0xA200's own offset to get an ordinal. + public uint FoliageOffset; + + /// 0xFFFFFFFF on all 757 metropolis instances. Not identified. + public uint Unk1; + + /// Bounding sphere in world space: 0xB0..0xB8 is the centre and 0xBC the radius. The + /// centre tracks the matrix translation closely and the radius is a small positive float, both + /// consistent with the same layout UFragMetadata uses at its own 0x30/0x3C. NOT verified + /// against the actual sprite extents the way the UFrag one was - treat the radius as a + /// candidate and prefer computing bounds from geometry if a cull ever looks wrong. + public Vector4 BoundingSphere; + + public static FoliageInstance Read(StreamHelper sh, uint recordBase) + { + sh.Seek(recordBase); + var m = new Matrix4x4( + sh.ReadSingle(), sh.ReadSingle(), sh.ReadSingle(), sh.ReadSingle(), + sh.ReadSingle(), sh.ReadSingle(), sh.ReadSingle(), sh.ReadSingle(), + sh.ReadSingle(), sh.ReadSingle(), sh.ReadSingle(), sh.ReadSingle(), + sh.ReadSingle(), sh.ReadSingle(), sh.ReadSingle(), sh.ReadSingle()); + + sh.Seek(recordBase + 0xB0); + var sphere = new Vector4(sh.ReadSingle(), sh.ReadSingle(), sh.ReadSingle(), sh.ReadSingle()); + + return new FoliageInstance + { + Transform = m, + BoundingSphere = sphere, + FoliageOffset = sh.ReadUInt32(recordBase + 0xC4), + Unk1 = sh.ReadUInt32(recordBase + 0xC8), + }; + } + + public readonly byte[] ToBytes(bool isOld, params object[]? additionalParams) => throw new NotImplementedException(); +} diff --git a/ReLunacy.Engine/Loading/Objects/Instances/TieInstance.cs b/ReLunacy.Engine/Loading/Objects/Instances/TieInstance.cs index 8372075..bb60e71 100644 --- a/ReLunacy.Engine/Loading/Objects/Instances/TieInstance.cs +++ b/ReLunacy.Engine/Loading/Objects/Instances/TieInstance.cs @@ -9,7 +9,7 @@ namespace ReLunacy.Engine.Loading.Objects.Instances; [FileStructure(0x80)] public record struct TieInstance : ILunaSerializable { - /// New-engine tie instance struct array section. 0x72C0 (formerly used here) is actually the tie name pointer table — see . + /// New-engine tie instance struct array section. 0x72C0 (formerly used here) is actually the tie name pointer table - see . public const uint ID = 0x7240; public const uint OldID = 0x9240; /// New engine only: tie instance name pointer table, positionally matched to the instance array. @@ -24,7 +24,7 @@ public record struct TieInstance : ILunaSerializable /// This instance's baked lighting: entry X in BOTH zone section 0x5400 (light colour) /// and 0x5410 (tangent-space light direction). 0xFFFF = none. - /// Lives in the low 16 bits of the u32 at 0x58 (Unk[4..8]) — i.e. bytes 0x5A/0x5B big-endian. + /// Lives in the low 16 bits of the u32 at 0x58 (Unk[4..8]) - i.e. bytes 0x5A/0x5B big-endian. /// VERIFIED against metropolis/main.dat: of 4848 tie instances, 1728 carry an index, every one /// of them DISTINCT, covering 0..1742 of that level's 1751 lightmap entries with no reuse. The /// high 16 bits are 0 in every instance, which is why the old engine takes the low half. @@ -40,7 +40,7 @@ public record struct TieInstance : ILunaSerializable /// /// Old engine tie instances don't have the trailing Reference-indirected Unk field new-engine - /// ones do — reading it via the shared reflection path would interpret whatever old-engine + /// ones do - reading it via the shared reflection path would interpret whatever old-engine /// bytes happen to sit at 0x54 as a pointer and seek there, which is garbage for this engine /// and throws. Read the known-good fields directly instead. /// @@ -57,7 +57,7 @@ public static TieInstance ReadOld(StreamHelper sh) uint tieIndex = sh.ReadUInt32(); // Read the trailing 0x2C bytes as RAW DATA rather than skipping them. The reflection path - // can't be used here (it would treat these as a pointer and seek to garbage — see the + // can't be used here (it would treat these as a pointer and seek to garbage - see the // summary above), but they are not empty: the baked-lighting index lives at record offset // 0x5A, i.e. Unk[6..8]. This previously returned `Unk = []`, which silently made // LightmapIndex report "no lightmap" for every old-engine tie and left the entire baked diff --git a/ReLunacy.Engine/Loading/Objects/Moby.cs b/ReLunacy.Engine/Loading/Objects/Moby.cs index a60e0e1..2de11cf 100644 --- a/ReLunacy.Engine/Loading/Objects/Moby.cs +++ b/ReLunacy.Engine/Loading/Objects/Moby.cs @@ -30,7 +30,7 @@ public class Moby : IDisposable public MobyBangle[] Bangles => MobyObj.bangles; public ulong[]? ShaderTUIDs; - /// Null if this moby has no skeleton (static props etc.) or if reading one failed — + /// Null if this moby has no skeleton (static props etc.) or if reading one failed - /// see the catch below. Read defensively: this is new, unverified-against-every-real-asset /// code, and a bug in it must not be able to break loading for mobys that don't even reach it. public MobySkeleton? Skeleton { get; private set; } @@ -70,7 +70,7 @@ public class Moby : IDisposable } // New engine: geometry lives inside this moby's own IGFile as dedicated sections, - // not a raw-file offset field (there is none on NewMoby) — mirrors Tie's new-engine + // not a raw-file offset field (there is none on NewMoby) - mirrors Tie's new-engine // vertex/index section reads. var vertSec = igFile.QuerySection(MobyMesh.VerticesSecID); mobyStream.Seek(vertSec.offset); @@ -86,7 +86,7 @@ public class Moby : IDisposable return; // Some old-engine mobys (logic-only props: triggers, camera targets, path markers, - // etc. — confirmed present in Tools of Destruction's meridian_city) have zero bangles, + // etc. - confirmed present in Tools of Destruction's meridian_city) have zero bangles, // or a bangle with zero meshes: no visual geometry at all. bangles/meshes are // [Reference(...)]-deserialized arrays that stay null when their count is zero, so // blindly indexing bangles[^1].meshes[^1] (as this used to, four times below) threw a @@ -148,7 +148,7 @@ public class Moby : IDisposable } // Searches backward for the last bangle that actually has meshes (not necessarily the very - // last bangle — a moby could have trailing empty bangles too), since the whole point is + // last bangle - a moby could have trailing empty bangles too), since the whole point is // finding the true final mesh's offset/count to compute the total buffer length. Returns // false if this moby has no mesh data anywhere (null/empty bangles, or every bangle empty). private static bool TryGetLastMesh(MobyBangle[]? bangles, out MobyMesh lastMesh) @@ -179,7 +179,7 @@ private static bool TryGetLastMesh(MobyBangle[]? bangles, out MobyMesh lastMesh) public void Dispose() { // Same null-bangles/null-meshes possibility as the constructor guards against above (a - // moby with no visual geometry) — nothing was rented from either pool in that case, so + // moby with no visual geometry) - nothing was rented from either pool in that case, so // there's nothing to return either. if (MobyObj.bangles != null) { diff --git a/ReLunacy.Engine/Loading/Objects/MobyBone.cs b/ReLunacy.Engine/Loading/Objects/MobyBone.cs index a8fe312..a71b214 100644 --- a/ReLunacy.Engine/Loading/Objects/MobyBone.cs +++ b/ReLunacy.Engine/Loading/Objects/MobyBone.cs @@ -3,7 +3,7 @@ namespace ReLunacy.Engine.Loading.Objects; -/// On-disk bone record (8 bytes) — layout confirmed against InsomniaToolset's `Bone` +/// On-disk bone record (8 bytes) - layout confirmed against InsomniaToolset's `Bone` /// struct (common/include/insomnia/classes/moby.hpp), which documents the same fields at the same /// offsets for both engine generations. [FileStructure(0x08)] diff --git a/ReLunacy.Engine/Loading/Objects/MobySkeleton.cs b/ReLunacy.Engine/Loading/Objects/MobySkeleton.cs index e2132de..a8c6b59 100644 --- a/ReLunacy.Engine/Loading/Objects/MobySkeleton.cs +++ b/ReLunacy.Engine/Loading/Objects/MobySkeleton.cs @@ -5,13 +5,13 @@ namespace ReLunacy.Engine.Loading.Objects; /// -/// On-disk skeleton header (0x1C bytes) — layout confirmed against InsomniaToolset's `Skeleton` +/// On-disk skeleton header (0x1C bytes) - layout confirmed against InsomniaToolset's `Skeleton` /// struct, referenced identically (same field offsets) from both MobyV1 (old engine) and MobyV2 /// (new engine)'s own `skeleton` pointer field. /// -/// `tms0`/`tms1` are NOT read via the [Reference] array mechanism like `bones` — that mechanism +/// `tms0`/`tms1` are NOT read via the [Reference] array mechanism like `bones` - that mechanism /// (FileUtils.ReadStructureArray) requires the element type to carry its own [FileStructure] size, -/// which a raw System.Numerics.Matrix4x4 doesn't have — so MobySkeletonReader dereferences +/// which a raw System.Numerics.Matrix4x4 doesn't have - so MobySkeletonReader dereferences /// `tms0Pointer`/`tms1Pointer` manually, the same way RegionReader.ReadMatrix4x4 already does for /// volume placement matrices. /// @@ -31,10 +31,10 @@ public record struct MobySkeleton : ILunaSerializable [FileOffset(0x14)] public uint spuRefPoseBufferPointer; [FileOffset(0x18)] public uint unkOffsetPointer; - /// Bone i's bind-pose transform in moby-local space (not relative to its parent) — + /// Bone i's bind-pose transform in moby-local space (not relative to its parent) - /// populated manually by MobySkeletonReader, not by the [FileOffset]-driven reflection pass. public Matrix4x4[] tms0; - /// Inverse of tms0[i] — the matrix GPU skinning multiplies a vertex by, and also what + /// Inverse of tms0[i] - the matrix GPU skinning multiplies a vertex by, and also what /// InsomniaToolset composes against a child's tms0 to derive that child's parent-local transform /// (see MobySkeletonReader.ComputeLocalBindPose). public Matrix4x4[] tms1; diff --git a/ReLunacy.Engine/Loading/Objects/MobySkeletonReader.cs b/ReLunacy.Engine/Loading/Objects/MobySkeletonReader.cs index b2f39d6..59a4200 100644 --- a/ReLunacy.Engine/Loading/Objects/MobySkeletonReader.cs +++ b/ReLunacy.Engine/Loading/Objects/MobySkeletonReader.cs @@ -11,18 +11,18 @@ namespace ReLunacy.Engine.Loading.Objects; /// fabricate an entire fake IGFile/moby record. /// /// Pointer convention: `skeletonPointer` and the nested `tms0Pointer`/`tms1Pointer`/bone-array -/// pointer are all absolute offsets from the start of the moby's own stream — the exact same +/// pointer are all absolute offsets from the start of the moby's own stream - the exact same /// convention already proven correct by the (working) bangle/mesh [Reference] chain on /// NewMoby/OldMoby, so no per-engine adjustment is needed here. /// public static class MobySkeletonReader { /// - /// The Moby record's own bonesCount/bonesCount1 field, if known — the skeleton header carries + /// The Moby record's own bonesCount/bonesCount1 field, if known - the skeleton header carries /// its own redundant numBones copy, and the two disagreeing is the single strongest signal /// available (without ground-truth data) that skeletonPointer landed somewhere other than real /// skeleton data, e.g. a wrong pointer-base assumption for one engine. Surfaces as a loud, - /// visible failure (exception → caught by Moby's constructor → Skeleton stays null and a + /// visible failure (exception -> caught by Moby's constructor -> Skeleton stays null and a /// warning is logged) instead of silently exposing plausible-looking garbage bones. /// public static MobySkeleton? Read(StreamHelper sh, uint skeletonPointer, uint? expectedBoneCount = null) diff --git a/ReLunacy.Engine/Loading/Objects/NewMoby.cs b/ReLunacy.Engine/Loading/Objects/NewMoby.cs index 7ebdd98..f9366ce 100644 --- a/ReLunacy.Engine/Loading/Objects/NewMoby.cs +++ b/ReLunacy.Engine/Loading/Objects/NewMoby.cs @@ -21,7 +21,7 @@ public record struct NewMoby : IMoby [FileOffset(0x1C)] public ushort bonesCount1; [FileOffset(0x1E)] public ushort bonesCount2; [FileOffset(0x20)] public uint Unk3; - // banglesPointer only captures the raw pointer value for ToBytes round-tripping — the + // banglesPointer only captures the raw pointer value for ToBytes round-tripping - the // actual bangle records are read via the [Reference]-decorated newMobyBangles field below, // which independently seeks to this same offset and follows the pointer (see OldMoby's // identical mobyBangles/bangles split for the established pattern). diff --git a/ReLunacy.Engine/Loading/Objects/Tie.cs b/ReLunacy.Engine/Loading/Objects/Tie.cs index 27693d0..4b12384 100644 --- a/ReLunacy.Engine/Loading/Objects/Tie.cs +++ b/ReLunacy.Engine/Loading/Objects/Tie.cs @@ -25,6 +25,13 @@ public class Tie : IDisposable public ulong[]? ShaderTUIDs; + /// RAW lightmap UV channel, flat [u0,v0,u1,v1,...] over the tie's whole vertex buffer, + /// or null when the read isn't even in bounds. See - this is RSX + /// attribute location 4, which the game's tie vertex program routes into tc0.zw. + /// Not every window in here is real UV data; validate PER MESH before use, as + /// TieReader.SliceLightmapUVs does. + public float[]? LightmapUVs { get; private set; } + public Tie(IGFile file, FileManager fm, bool old = false, uint index = 0) { tieStream = file.sh; @@ -44,7 +51,7 @@ public Tie(IGFile file, FileManager fm, bool old = false, uint index = 0) verticesBuffer = new StreamHelper(new MemoryStream(data), tieStream._endianness); // Old engine's TieMesh fields live at different offsets than the [FileOffset] - // attributes (which target the new-engine layout) — re-read them manually. + // attributes (which target the new-engine layout) - re-read them manually. var meshesPtr = tieStream.ReadUInt32(section.offset + TieMetadataOld.Size * index); for (int mi = 0; mi < metadataOld.Value.meshesCount; mi++) { @@ -69,6 +76,8 @@ public Tie(IGFile file, FileManager fm, bool old = false, uint index = 0) var inddata = new byte[maxIndEnd]; verticesFile.sh.Read(inddata); indicesBuffer = new StreamHelper(new MemoryStream(inddata), tieStream._endianness); + + LightmapUVs = ReadLightmapUVsRaw(verticesFile, vertSec.offset, metadataOld.Value); } else { @@ -109,6 +118,52 @@ public Tie(IGFile file, FileManager fm, bool old = false, uint index = 0) } } + /// Reads the tie's lightmap UV array from the shared vertex blob, or returns null if + /// this tie doesn't have one there. + /// + /// The array is one per vertex, packed immediately after the tie's + /// own vertex block - metadata 0x18 is the block's END offset, so it doubles as this array's + /// start, and a mesh's own window begins at 0x18 + TieMesh.verticesIndex * 4. + /// + /// RETURNED RAW AND UNVALIDATED, deliberately. Validation is PER MESH and lives in + /// TieReader.SliceLightmapUVs, because baked lighting is a per-mesh decision: on metropolis only + /// 61 of 193 ties have a usable array for every one of their meshes, but 2082 of 3771 MESHES do, + /// spread over 173 ties. Gating the whole tie on "every vertex decodes in [0,1]" - which is what + /// this method used to do - threw away 112 ties that are partly baked, some of them carrying the + /// level's largest 256x256 lightmaps. A tie is not lit or unlit; its meshes are. + /// + /// Where a mesh's window is not real UV data it is usually zeros (an unshaded mesh's slot) or + /// unrelated bytes, and the per-mesh range check rejects the latter. Measured on the windows that + /// pass: area correlation (see TieLightmapUV) has median 0.870 over 701 scorable meshes with 490 + /// above 0.70 - the same range as the ties that were already working. + private static float[]? ReadLightmapUVsRaw(IGFile verticesFile, uint sectionOffset, in TieMetadataOld meta) + { + long span = (long)meta.verticesBufferSize - meta.verticesBufferStart; + if (span <= 0 || span % VertexFormat0.Size != 0) return null; + + int vertexCount = (int)(span / VertexFormat0.Size); + long start = sectionOffset + meta.verticesBufferSize; + if (start + (long)vertexCount * TieLightmapUV.Size > verticesFile.sh.BaseStream.Length) return null; + + float[] uvs; + try + { + uvs = TieLightmapUV.ReadArray(verticesFile.sh, (uint)start, vertexCount); + } + catch (EndOfStreamException) + { + return null; + } + + // NO V FLIP, and this is settled by trying it: flipping V here was tested in the running + // app and looked worse, so it is gone. That matches the file evidence - the game's vertex + // program passes location 4 into tc0.zw completely untransformed (see TieLightmapUV), so + // these bytes are already in the sampler's convention. If tie bakes ever look vertically + // wrong again, the cause is downstream (the shared lightmap sampling path that UFrags also + // use), not here; flipping in this method would only desynchronise ties from terrain. + return uvs; + } + public byte[] ToBytes(params object[]? args) => isOld ? metadataOld!.Value.ToBytes(isOld, args) : metadataNew!.Value.ToBytes(isOld, args); public void Dispose() diff --git a/ReLunacy.Engine/Loading/Objects/TieMetadata.cs b/ReLunacy.Engine/Loading/Objects/TieMetadata.cs index 1698f88..339c2eb 100644 --- a/ReLunacy.Engine/Loading/Objects/TieMetadata.cs +++ b/ReLunacy.Engine/Loading/Objects/TieMetadata.cs @@ -16,7 +16,26 @@ public record struct TieMetadataOld : ILunaObject, ILunaSerializable [FileOffset(0x0F)] public byte meshesCount; [FileOffset(0x10)] public uint Unk2; [FileOffset(0x14)] public uint verticesBufferStart; + /// Despite the name, this is the vertex block's END offset, not its size - both + /// relative to section 0x9000's start, same basis as . + /// Measured on metropolis: 0x18-0x14 is exactly vertexCount*20 for 186 of 193 ties (and is + /// divisible by 20 for all 193), while 0x18 alone equals vertexCount*20 for 0 of 193. So the + /// tie's vertex count is (0x18-0x14)/20, and 0x18 is where its vertex data stops. + /// Tie.cs reads this as a length and pulls that many bytes from verticesBufferStart, which + /// over-reads by verticesBufferStart bytes. Harmless - meshes index in by vertex, so nothing + /// downstream sees the surplus - but it is not what the field means. + /// This offset also matters for baked lighting: for 61 of the 193 ties the lightmap UV array + /// begins exactly here, which is what Tie.TryReadLightmapUVs reads. See + /// Loading.Vertices.TieLightmapUV for the proof, and for the searched-and-not-found result on + /// the other 132. [FileOffset(0x18)] public uint verticesBufferSize; + /// Not an offset - a FLOAT (observed 4992.0, 504.0, 752.0 on metropolis; read as a + /// uint these are 0x459C0000 / 0x43FC0000 / 0x443C0000). Insomniac's own post-mortem for this + /// game (dev/Ratchet_and_Clank_WWS_Debrief_Feb_08.pdf, "Shader Usage Controls") lists per-use + /// knobs that are exactly this shape: transition distance for discrete LOD, for fade-out LOD, + /// for shader LOD, and the detail-map fade distance. A world-space distance in the hundreds to + /// low thousands fits any of them. CANDIDATE ONLY - nothing has tied this value to observed LOD + /// behaviour yet, and there are four knobs it could be. [FileOffset(0x1C)] public uint Unk3; [FileOffset(0x20)] public Vector3 scale; [FileOffset(0x64)] public uint nameOffset; diff --git a/ReLunacy.Engine/Loading/Objects/UFrag.cs b/ReLunacy.Engine/Loading/Objects/UFrag.cs index 73c06ce..42de52b 100644 --- a/ReLunacy.Engine/Loading/Objects/UFrag.cs +++ b/ReLunacy.Engine/Loading/Objects/UFrag.cs @@ -18,16 +18,20 @@ public class UFrag : IDisposable, IMesh public float[] vpos { get; set; } = []; public uint[] indices { get; set; } = []; public float[] uvs { get; set; } = []; - /// Second UV set (UFragVertex.UVs2) — the LIGHTMAP UV channel. The captured game + /// Second UV set (UFragVertex.UVs2) - the LIGHTMAP UV channel. The captured game /// shader samples its baked light colour and light direction maps (zone sections 0x5400 and /// 0x5410) at a second UV, and this is it. Parsed off disk since the vertex format was first /// implemented but discarded here until lighting needed it. public float[] uvs2 { get; set; } = []; // 3 floats per vertex, decoded from the same signed 11:11:10 packed words VertexFormat0/1 use - // (see PackedNormal) — tangent handedness (the 4th component) is derived later, in + // (see PackedNormal) - tangent handedness (the 4th component) is derived later, in // ZoneReader.ConvertUFrag, since the packed word spends all 32 bits on xyz. public float[] normals { get; set; } = []; public float[] tangents { get; set; } = []; + // Same field/decode as VertexFormat0.boneIndex on Ties (UFragVertex.unk, see its + // VertexAlphaCandidate) - UFrags have no skeleton either, so there's no competing bone-index + // use of the field the way there is on Mobys. + public float[] vertexAlphaCandidates { get; set; } = []; public uint[] boneWeight { get; set; } = []; public uint[] vertToBonemap { get; set; } = []; @@ -47,12 +51,12 @@ public void ReadVertices() { // UFragVertex's constructor already reads its fields sequentially and ends exactly at // recordBase + Size on its own (unlike UFragMetadata's constructor, which jumps around - // and doesn't) — advancing the stream again here double-skips, silently dropping every + // and doesn't) - advancing the stream again here double-skips, silently dropping every // other vertex and misaligning the rest against the index buffer. vertices[i] = new(geometryStream); } - // vertices.Length is the pool's rented capacity, not the real count — ArrayPool.Rent only + // vertices.Length is the pool's rented capacity, not the real count - ArrayPool.Rent only // guarantees a length >= requested, rounding up to the next bucket size. Sizing off it // (instead of metadata.vertexCount) drags in whatever stale data from a previous tenant of // that buffer happened to be sitting past the real vertex count. @@ -61,6 +65,7 @@ public void ReadVertices() uvs2 = new float[metadata.vertexCount * 2]; normals = new float[metadata.vertexCount * 3]; tangents = new float[metadata.vertexCount * 3]; + vertexAlphaCandidates = new float[metadata.vertexCount]; for (int i = 0; i < metadata.vertexCount; i++) { vpos[i * 3 + 0] = vertices[i].position.Item1; @@ -71,7 +76,7 @@ public void ReadVertices() uvs2[i * 2 + 0] = (float)vertices[i].UVs2.Item1; uvs2[i * 2 + 1] = (float)vertices[i].UVs2.Item2; - // Same decode as VertexFormat0/1 (signed 11:11:10, X low bits) — the raw words were + // Same decode as VertexFormat0/1 (signed 11:11:10, X low bits) - the raw words were // always read off disk (UFragVertex 0x10/0x14) but were dropped here until real // lighting needed them, which left every UFrag lit as if all its faces pointed // straight up (Vector3.UnitY fallback in EntityUFrag). @@ -83,6 +88,8 @@ public void ReadVertices() tangents[i * 3 + 0] = t.X; tangents[i * 3 + 1] = t.Y; tangents[i * 3 + 2] = t.Z; + + vertexAlphaCandidates[i] = vertices[i].VertexAlphaCandidate; } } diff --git a/ReLunacy.Engine/Loading/Objects/UFragMetadata.cs b/ReLunacy.Engine/Loading/Objects/UFragMetadata.cs index 47dd501..32f5c06 100644 --- a/ReLunacy.Engine/Loading/Objects/UFragMetadata.cs +++ b/ReLunacy.Engine/Loading/Objects/UFragMetadata.cs @@ -23,11 +23,11 @@ public struct UFragMetadata : ILunaSerializable public byte[] Unk3; /// This UFrag's entry in the zone's baked light colour (0x5400) and light direction - /// (0x5410) lists — a shared ATLAS, not a private bake: 1377 of metropolis's 1987 UFrags are + /// (0x5410) lists - a shared ATLAS, not a private bake: 1377 of metropolis's 1987 UFrags are /// lightmapped across just 23 atlases, each UFrag occupying its own island via UFragVertex.UVs2. /// 0xFFFF = none (610 UFrags). Read from old-engine offset 0x4E; see the constructor. /// Supersedes an earlier reading at 0x52, which was 0xFFFF for every UFrag in the level and so - /// made terrain look unlit — it was taken from ReLunacy-Ymir on trust and never held up here. + /// made terrain look unlit - it was taken from ReLunacy-Ymir on trust and never held up here. /// public ushort lightmapIndex; @@ -44,7 +44,7 @@ public UFragMetadata(StreamHelper sh, bool oldEngine, int index = 0) // start, not the file's. Unlike FileUtils.ReadStructure (which captures this // automatically via [FileOffset]), this constructor is hand-written and reads through // StreamHelper's ReadXxx(offset)/Seek(offset) overloads, which all seek absolutely from - // the start of the stream — so every literal offset here must be based off where this + // the start of the stream - so every literal offset here must be based off where this // record actually begins, or every UFrag past the first in a zone reads from the wrong // place in the file entirely (previously missing, causing garbage/absent geometry). uint recordBase = (uint)sh.Offset; @@ -56,7 +56,7 @@ public UFragMetadata(StreamHelper sh, bool oldEngine, int index = 0) indexOffset = sh.ReadUInt32(recordBase + 0x40) * sizeof(ushort); Unk3 = sh.ReadFromOffset(0x0E, recordBase + 0x52); // 0x4E, not 0x52. Terrain shares ATLASES rather than taking one bake each, so this - // index has low cardinality — which is why earlier scans looking for a dense per-UFrag + // index has low cardinality - which is why earlier scans looking for a dense per-UFrag // index missed it entirely. // Verified on metropolis: 23 distinct values across 1987 UFrags (610 are 0xFFFF), and // every one of the 23 resolves to a 256x256 or 128x128 A8R8G8B8 entry in BOTH 0x5400 @@ -66,13 +66,22 @@ public UFragMetadata(StreamHelper sh, bool oldEngine, int index = 0) // real alpha channel, so the "alpha = monochrome specular light" reading is genuinely // populated for terrain. lightmapIndex = sh.ReadUInt16(recordBase + 0x4E); + // Placement anchor, fixed-point x256 - ZoneReader divides. (0x6C, the float that would + // follow it, is NaN on every UFrag in metropolis, so this is a Vector3 field and not a + // sphere; reading a radius there is what forced the old 2.5f fallback.) sh.Seek(recordBase + 0x60); position = new Vector3(sh.ReadSingle(), sh.ReadSingle(), sh.ReadSingle()); - sh.Seek(recordBase + 0x60); + // REAL bounding sphere: centre at 0x30 and radius at 0x3C, both plain world-space + // floats needing no x256 decode. Verified against each UFrag's own decoded vertices on + // metropolis (all 1987): the radius at 0x3C matches the sphere those vertices actually + // describe to a median relative error of 0.0001, with 99.7% inside 10%, it is never + // negative, and it spans 0.303..89.194 - so the 2.5f constant this replaces was wrong + // for essentially every UFrag and made frustum culling drop large terrain chunks early. + // The centre agrees with the anchor above to a median of 0.0025 world units (the two + // describe the same point; 0x30 just carries full float precision instead of 1/256). + sh.Seek(recordBase + 0x30); boundingSphere = new Vector4(sh.ReadSingle(), sh.ReadSingle(), sh.ReadSingle(), sh.ReadSingle()); Unk4 = sh.ReadFromOffset(0x14, recordBase + 0x6C); - // Old engine's real grid anchor hasn't been located yet — fall back to the - // bounding-sphere position, same as before this field existed. newEnginePos = position; Unk3b = []; } @@ -86,7 +95,7 @@ public UFragMetadata(StreamHelper sh, bool oldEngine, int index = 0) indexOffset = sh.ReadUInt32(recordBase + 0x40); Unk3 = sh.ReadFromOffset(0x1E, recordBase + 0x52); // New engine keeps its lightmap/directional indices in zone section 0x6400 (one 0x10 - // entry per UFrag, lightmapindex at 0x06 and directionalindex at 0x08 — see Ymir), not + // entry per UFrag, lightmapindex at 0x06 and directionalindex at 0x08 - see Ymir), not // in this record. Not parsed yet, so no baked lighting is claimed for these. lightmapIndex = NoLightmap; sh.Seek(recordBase + 0x70); diff --git a/ReLunacy.Engine/Loading/Readers/CubemapReader.cs b/ReLunacy.Engine/Loading/Readers/CubemapReader.cs new file mode 100644 index 0000000..96aa486 --- /dev/null +++ b/ReLunacy.Engine/Loading/Readers/CubemapReader.cs @@ -0,0 +1,202 @@ +using ReLunacy.Engine.Assets.Interfaces; +using ReLunacy.Engine.Loading.IO; +using ReLunacy.Engine.Loading.Textures; +using AssetTexture = ReLunacy.Engine.Assets.Textures.Texture; + +namespace ReLunacy.Engine.Loading.Readers; + +/// Reads old-engine environment cubemaps: section 0x5920, one +/// record per cubemap, with pixel data in MAIN.DAT (not textures.dat) at the record's own offset. +/// +/// On-disk layout, established byte-for-byte against metropolis and matched to a RenderDoc capture +/// of the live cubemap: +/// - 6 faces, order +X,-X,+Y,-Y,+Z,-Z (GL/RSX), face-major with the full mip chain per face +/// (largest first), each face padded to a 128-byte STRIDE (5460 mip bytes -> 5504). +/// - Faces are Morton/GCM-SWIZZLED, despite the metadata's linear bit reading set - the bit does +/// not describe this texture correctly, so faces are always un-swizzled here. +/// - A small all-zero-alpha HEADER precedes the first face (0x380 on metropolis). Rather than +/// hard-code it, the first face is located by a short alignment search (see FindFaceBase): +/// the real faces are the smoothest coherent 32x32 images in the region, which pins the header +/// size without assuming it. +/// +/// Only A8R8G8B8 is handled (the only format seen). Records with offset 0 are stubs (e.g. kerchu +/// city, which ships a placeholder and no cubemap pixels) and are skipped. New engine is not +/// handled - its cubemaps are an assetlookup resource (InsomniaToolset ResourceCubemap 0x1d200), +/// a different path entirely. +public sealed class CubemapReader +{ + public const uint ID = 0x5920; + + // Faces are aligned to 128 bytes; the leading header on the one confirmed sample is 0x380. The + // search below walks 128-byte candidate offsets up to this cap and never depends on the exact + // value. + private const int FaceAlignment = 128; + private const int MaxHeaderSearch = 0x1000; + private const int FaceCount = 6; + + private readonly FileManager _fileManager; + + public CubemapReader(FileManager fileManager) + { + _fileManager = fileManager ?? throw new ArgumentNullException(nameof(fileManager)); + } + + public IReadOnlyList ReadAll() + { + if (!_fileManager.isOld) return []; + if (!_fileManager.igfiles.TryGetValue("main.dat", out IGFile? main) || main is null) return []; + + var section = main.QuerySection(ID); + if (section.id != ID) return []; + + // The section's `count` field carries the unreliable flag the rest of the loader already + // works around (kerchu city reports count=4 for a single stub record) - length / record + // size is the real count. + int recordCount = (int)(section.length / TextureMetadataOld.Size); + var result = new List(); + + for (int r = 0; r < recordCount; r++) + { + main.sh.Seek(section.offset + (long)r * TextureMetadataOld.Size); + var meta = TextureMetadataOld.Read(main.sh); + + if (meta.offset == 0 || meta.Width == 0 || meta.Height == 0) + continue; // stub / no pixel data (see class comment) + if (meta.Format != Textures.TextureFormat.A8R8G8B8) + { + Console.WriteLine($"Cubemap {r}: unhandled format {meta.Format}, skipped."); + continue; + } + + var cubemap = ReadCubemap(main, meta, (uint)(section.offset + (long)r * TextureMetadataOld.Size)); + if (cubemap != null) + result.Add(cubemap); + } + + if (result.Count != 0) + Console.WriteLine($"Cubemaps: {result.Count} loaded ({string.Join(", ", result.Select(c => $"{c.FaceSize}x{c.FaceSize}"))})."); + return result; + } + + private Assets.Cubemaps.Cubemap? ReadCubemap(IGFile main, in TextureMetadataOld meta, uint recordBase) + { + int size = (int)meta.Width; // faces are square + int faceBytes = size * size * 4; // mip0, A8R8G8B8 + int stride = AlignUp(MipChainBytes(size, meta.MipmapCount), FaceAlignment); + + long streamLen = main.sh.BaseStream.Length; + long available = streamLen - meta.offset; + int needed = MaxHeaderSearch + FaceCount * stride; + int toRead = (int)Math.Min(needed, available); + if (toRead < FaceCount * stride) return null; // not enough data for six faces + + byte[] region = main.sh.ReadFromOffset(toRead, meta.offset); + + int header = FindFaceBase(region, size, stride); + if (header < 0) + { + Console.WriteLine($"Cubemap 0x{recordBase:X}: could not locate faces, skipped."); + return null; + } + + var faces = new ITexture[FaceCount]; + for (int f = 0; f < FaceCount; f++) + { + int faceOffset = header + f * stride; + // Un-swizzle mip0 into row-major ARGB, then hand it to the asset-facing Texture as + // A8R8G8B8 so the shared DecodeToRgba8888 path (ARGB->RGBA) previews/exports it exactly + // like any other texture. + byte[] argb = Textures.Texture.Deswizzle(region.AsSpan(faceOffset, faceBytes), size, size, 4); + faces[f] = AssetTexture.FromData( + MakeFaceId(recordBase, f), (uint)size, (uint)size, + Assets.Interfaces.TextureFormat.A8R8G8B8, argb); + } + + return new Assets.Cubemaps.Cubemap(recordBase, size, faces); + } + + /// Finds the first face's offset within the region. The leading header is all-zero + /// alpha, so the real faces are the first 128-aligned position where all six un-swizzled faces + /// carry actual data (alpha varies) and read as coherent images (low local alpha gradient). + /// Returns the offset of face +X's mip0, or -1 if no plausible alignment was found. + private static int FindFaceBase(byte[] region, int size, int stride) + { + int faceBytes = size * size * 4; + int maxHeader = Math.Min(MaxHeaderSearch, region.Length - FaceCount * stride); + + int bestOffset = -1; + double bestGradient = double.MaxValue; + + for (int header = 0; header <= maxHeader; header += FaceAlignment) + { + double worstStd = double.MaxValue; + double totalGradient = 0; + for (int f = 0; f < FaceCount; f++) + { + byte[] face = Textures.Texture.Deswizzle( + region.AsSpan(header + f * stride, faceBytes), size, size, 4); + (double std, double grad) = AlphaStats(face, size); + worstStd = Math.Min(worstStd, std); + totalGradient += grad; + } + + // Near-constant faces (the all-zero-alpha header, or padding) are not real faces even + // though they are perfectly "smooth"; require every face to carry signal first, then + // pick the alignment whose faces are the most image-like. + if (worstStd < 5.0) continue; + if (totalGradient < bestGradient) + { + bestGradient = totalGradient; + bestOffset = header; + } + } + + return bestOffset; + } + + /// Standard deviation and mean absolute neighbour-difference of a face's ALPHA channel + /// (byte 0 of each ARGB texel) - the channel that carries the cubemap's signal. + private static (double Std, double Gradient) AlphaStats(byte[] argbFace, int size) + { + int n = size * size; + double sum = 0, sumSq = 0; + for (int i = 0; i < n; i++) + { + int a = argbFace[i * 4]; + sum += a; + sumSq += a * (double)a; + } + double mean = sum / n; + double std = Math.Sqrt(Math.Max(0, sumSq / n - mean * mean)); + + double gradSum = 0; + int gradCount = 0; + for (int y = 0; y < size; y++) + { + for (int x = 0; x < size; x++) + { + int a = argbFace[(y * size + x) * 4]; + if (x + 1 < size) { gradSum += Math.Abs(a - argbFace[(y * size + x + 1) * 4]); gradCount++; } + if (y + 1 < size) { gradSum += Math.Abs(a - argbFace[((y + 1) * size + x) * 4]); gradCount++; } + } + } + return (std, gradCount == 0 ? 0 : gradSum / gradCount); + } + + private static int MipChainBytes(int size, int mipCount) + { + int total = 0; + for (int m = 0; m < Math.Max(1, mipCount); m++) + { + int w = Math.Max(1, size >> m); + total += w * w * 4; + } + return total; + } + + private static int AlignUp(int value, int alignment) => (value + alignment - 1) / alignment * alignment; + + // A stable, unique id per face for the texture table / caches: the cubemap record's own offset + // in the high bits, the face index in the low bits. + private static ulong MakeFaceId(uint recordBase, int face) => ((ulong)recordBase << 8) | (uint)face; +} diff --git a/ReLunacy.Engine/Loading/Readers/DebugReader.cs b/ReLunacy.Engine/Loading/Readers/DebugReader.cs index 341d6f4..9aa770e 100644 --- a/ReLunacy.Engine/Loading/Readers/DebugReader.cs +++ b/ReLunacy.Engine/Loading/Readers/DebugReader.cs @@ -9,14 +9,14 @@ public sealed class DebugReader private readonly Dictionary _mobyPrototypeNames = []; private readonly Dictionary _tiePrototypeNames = []; private readonly Dictionary _shaderNames = []; - // Old engine only (Legacy never keys these by tuid — CMoby/CTie instance names are matched + // Old engine only (Legacy never keys these by tuid - CMoby/CTie instance names are matched // purely by array position: names[i].name against instance[i]). New engine reads instance // names from gp_prius.dat (mobys) or the zone's own file (ties), not debug.dat. private readonly List _mobyInstanceNames = []; private readonly List _tieInstanceNames = []; - // Index-aligned with the old-engine volume transform array (section 0x7740 in gameplay.dat — + // Index-aligned with the old-engine volume transform array (section 0x7740 in gameplay.dat - // see RegionReader.ReadVolumesOld), same as _mobyInstanceNames/_tieInstanceNames above. Must - // stay List with an unconditional Add per entry, not List skipping empties — + // stay List with an unconditional Add per entry, not List skipping empties - // skipping any entry desyncs every name after it from its actual volume index. private readonly List _volumeNames = []; private readonly bool _isOld; @@ -82,7 +82,7 @@ private void LoadTieInstanceNames() _tieInstanceNames.Add(string.IsNullOrEmpty(item.name) ? null : item.name); } - // Old engine assets have no real TUID — Legacy's DebugFile.GetMobyPrototypeName/ + // Old engine assets have no real TUID - Legacy's DebugFile.GetMobyPrototypeName/ // GetTiePrototypeName index directly into this array by the asset's own flat index // (CMoby/CTie old-engine constructors call debug.GetMobyPrototypeName(index)), ignoring // whatever is in the DebugAssetName.tuid field at that position. New engine assets do have @@ -138,9 +138,9 @@ private void LoadVolumeNames() if (section.count == 0) return; // Previously read via a bare loop of sh.ReadString() calls with no seek to section.offset - // first — it read from wherever the stream happened to be left by LoadShaderNames() just + // first - it read from wherever the stream happened to be left by LoadShaderNames() just // before it, not this section's actual data, and skipped adding an entry at all for empty - // names instead of preserving the slot — desyncing every name after the first gap from its + // names instead of preserving the slot - desyncing every name after the first gap from its // real volume index. Same struct/pattern as moby/tie instance names fixes both. _debugFile.sh.Seek(section.offset); var names = FileUtils.ReadStructureArray(_debugFile.sh, section.count); diff --git a/ReLunacy.Engine/Loading/Readers/FoliageReader.cs b/ReLunacy.Engine/Loading/Readers/FoliageReader.cs new file mode 100644 index 0000000..84a36ae --- /dev/null +++ b/ReLunacy.Engine/Loading/Readers/FoliageReader.cs @@ -0,0 +1,201 @@ +using System.Numerics; +using ReLunacy.Engine.Loading.IO; +using ReLunacy.Engine.Loading.Objects; +using ReLunacy.Engine.Loading.Objects.Instances; +using ReLunacy.Engine.Loading.Vertices; + +namespace ReLunacy.Engine.Loading.Readers; + +/// Reads old-engine foliage: the card sets in section 0xA200 and their placements in +/// 0x9340. See and for the format +/// and the capture that confirms it. +/// +/// New engine is not handled. Its equivalents have different section IDs (InsomniaToolset's +/// FoliageV2 family) and nothing has been verified against them, so ReadAll returns empty rather +/// than reading old-engine offsets out of a new-engine file. +public sealed class FoliageReader +{ + private readonly FileManager _fileManager; + private readonly MaterialReader? _materialReader; + + /// Resolves each asset's atlas from its direct texture index + /// (A200+0x08 into the 0x5200 table). Optional: when null (e.g. a standalone geometry-only read) + /// foliage still loads, just with no material, and the renderer falls back to the default + /// billboard texture. + public FoliageReader(FileManager fileManager, MaterialReader? materialReader = null) + { + _fileManager = fileManager ?? throw new ArgumentNullException(nameof(fileManager)); + _materialReader = materialReader; + } + + public IReadOnlyList ReadAll() + { + if (!_fileManager.isOld) return []; + if (!_fileManager.igfiles.TryGetValue("main.dat", out IGFile? main) || main is null) return []; + if (!_fileManager.igfiles.TryGetValue("vertices.dat", out IGFile? vertices) || vertices is null) return []; + + IGFile.SectionHeader assets; + try + { + assets = main.QuerySection(FoliageMetadata.ID); + } + catch + { + // A level with no foliage simply has no 0xA200 section - not an error worth failing + // the whole level load over. + return []; + } + + var vertSection = vertices.QuerySection(VertexFormat0.OldID); + var byOffset = new Dictionary(); + var result = new List(); + + for (uint i = 0; i < assets.count; i++) + { + uint recordBase = (uint)(assets.offset + FoliageMetadata.Size * i); + var meta = FoliageMetadata.Read(main.sh, recordBase); + + var sprites = ReadSprites(vertices.sh, (uint)vertSection.offset, meta); + + // Resolve the atlas straight from the record's direct texture index (A200+0x08 → + // 0x5200[index], see FoliageMetadata.TextureIndex). Null for the 0xFFFFFFFF sentinel, an + // out-of-range index, or a null materialReader — the asset then keeps a null material + // and the renderer draws the default billboard texture rather than crashing. + var material = _materialReader?.GetFoliageMaterial(meta.TextureIndex); + + byOffset[recordBase] = result.Count; + result.Add(new Assets.Foliage.Foliage( + id: recordBase, + metadata: meta, + sprites: sprites, + placements: [], + material: material)); + } + + AttachPlacements(main, result, byOffset); + LogSummary(result); + return result; + } + + /// Prints what was actually decoded. This is the only thing that exercises the reader + /// end to end - the format was verified offline against the same bytes, but a silent zero here + /// would otherwise look identical to a level that genuinely has no foliage. For metropolis the + /// expected line is 2 assets, 117 sprites each, LODs 58/30/17/11/1, 757 placements total. + private static void LogSummary(List foliages) + { + if (foliages.Count == 0) + { + Console.WriteLine("Foliage: none (no 0xA200 section, or new engine)."); + return; + } + + int placements = foliages.Sum(f => f.Placements.Count); + Console.WriteLine($"Foliage: {foliages.Count} asset(s), {placements} placement(s)."); + foreach (var f in foliages) + { + var lods = string.Join("/", f.Metadata.SpriteLodRanges.Where(r => r.CornerCount > 0).Select(r => r.SpriteCount)); + // Report how the direct texture index resolved — an unresolved index or a wrong 0x5200 + // ordering would otherwise be invisible until the atlas rendered wrong on screen. For + // metropolis both assets should read 0x5200[0]/[1] as 512x512 DXT5. + string tex = !f.Metadata.HasTexture + ? "none (0xFFFFFFFF)" + : f.Material?.AlbedoTexture is { } a + ? $"0x5200[{f.Metadata.TextureIndex}] → {a.Width}x{a.Height} {a.Format}" + : $"{f.Metadata.TextureIndex} (unresolved)"; + Console.WriteLine($" {f.Name}: {f.Sprites.Count} sprite(s) [LODs {lods}], " + + $"{f.Placements.Count} placement(s), texture {tex}"); + } + } + + /// Expands the card set into flat sprite records. Each card is four corners of the + /// per-corner array plus the one anchor record that four corners share - that grouping is the + /// frequency=4 divisor from the capture, not an assumption about ordering. + private static List ReadSprites(StreamHelper sh, uint sectionOffset, in FoliageMetadata meta) + { + var cards = new List(); + int total = meta.TotalCorners; + if (total <= 0 || total % FoliageSpriteCorner.CornersPerSprite != 0) return cards; + + int spriteCount = meta.TotalSprites; + long cornersEnd = sectionOffset + meta.SpriteCornerOffset + (long)total * FoliageSpriteCorner.Size; + long anchorsEnd = sectionOffset + meta.SpriteAnchorOffset + (long)spriteCount * FoliageSpriteAnchor.Size; + if (cornersEnd > sh.BaseStream.Length || anchorsEnd > sh.BaseStream.Length) return cards; + + var corners = new FoliageSpriteCorner[total]; + sh.Seek(sectionOffset + meta.SpriteCornerOffset); + for (int i = 0; i < total; i++) corners[i] = FoliageSpriteCorner.Read(sh); + + var anchors = new FoliageSpriteAnchor[spriteCount]; + sh.Seek(sectionOffset + meta.SpriteAnchorOffset); + for (int i = 0; i < spriteCount; i++) anchors[i] = FoliageSpriteAnchor.Read(sh); + + for (int s = 0; s < spriteCount; s++) + { + int c0 = s * FoliageSpriteCorner.CornersPerSprite; + var offsets = new Vector2[FoliageSpriteCorner.CornersPerSprite]; + var uvs = new Vector2[FoliageSpriteCorner.CornersPerSprite]; + for (int k = 0; k < FoliageSpriteCorner.CornersPerSprite; k++) + { + var c = corners[c0 + k]; + offsets[k] = new Vector2(c.OffsetX, c.OffsetY); + // V arrives negative (the file's atlas convention runs the opposite way to this + // renderer's) - negate rather than clamp or abs, or a card samples the mirrored + // quadrant instead of its own. See FoliageSpriteCorner. + uvs[k] = new Vector2(c.U, -c.V); + } + + var a = anchors[s]; + cards.Add(new Assets.Foliage.FoliageSpriteCard( + Anchor: new Vector3(a.X, a.Y, a.Z), + CornerOffsets: offsets, + Uvs: uvs, + Packed: (a.Packed0, a.Packed1), + Lod: LodOf(meta, c0))); + } + + return cards; + } + + /// Which sprite LOD a corner index falls in. Ranges are consecutive and half-open, so + /// the first range whose end is past the index owns it. + private static int LodOf(in FoliageMetadata meta, int cornerIndex) + { + for (int i = 0; i < meta.SpriteLodRanges.Length; i++) + { + var r = meta.SpriteLodRanges[i]; + if (r.CornerCount > 0 && cornerIndex >= r.CornerBegin && cornerIndex < r.CornerEnd) return i; + } + return 0; + } + + private static void AttachPlacements(IGFile main, List assets, Dictionary byOffset) + { + IGFile.SectionHeader instances; + try + { + instances = main.QuerySection(FoliageInstance.ID); + } + catch + { + return; + } + + var lists = new List[assets.Count]; + for (int i = 0; i < lists.Length; i++) lists[i] = []; + + for (uint i = 0; i < instances.count; i++) + { + uint recordBase = (uint)(instances.offset + FoliageInstance.Size * i); + var inst = FoliageInstance.Read(main.sh, recordBase); + + // Instances whose pointer doesn't land on a parsed asset are dropped rather than + // clamped to asset 0 - a mis-sized record would otherwise pile every placement onto + // one plant and look like a loader that "worked". + if (!byOffset.TryGetValue(inst.FoliageOffset, out int index)) continue; + + lists[index].Add(new Assets.Foliage.FoliagePlacement(inst.Transform, inst.BoundingSphere)); + } + + for (int i = 0; i < assets.Count; i++) assets[i].SetPlacements(lists[i]); + } +} diff --git a/ReLunacy.Engine/Loading/Readers/LevelReader.cs b/ReLunacy.Engine/Loading/Readers/LevelReader.cs index 1f228d0..da28f5d 100644 --- a/ReLunacy.Engine/Loading/Readers/LevelReader.cs +++ b/ReLunacy.Engine/Loading/Readers/LevelReader.cs @@ -16,12 +16,16 @@ public sealed class LevelReader private MobyReader _mobyReader = null!; private TieReader _tieReader = null!; private ZoneReader _zoneReader = null!; + private FoliageReader _foliageReader = null!; private RegionReader _regionReader = null!; private Dictionary? _mobys; private Dictionary? _ties; private Dictionary? _zones; private Assets.Levels.Region? _region; + private IReadOnlyList? _foliages; + private IReadOnlyList? _cubemaps; + private Assets.Lighting.LightingEnvironment? _lightingEnvironment; public LevelReader(FileManager fileManager) { @@ -74,9 +78,26 @@ public LevelData LoadLevel(Action? progressCallback = null, bool _region.Zones = [.. _zones!.Values]; } + // Foliage is independent of everything above (its own asset section and its own instance + // section), so it loads regardless of which of the mobys/ties/zones flags are set. Old + // engine only - FoliageReader returns empty on new-engine files rather than reading + // old-engine offsets out of them. + progressCallback?.Invoke("Loading Foliage...", 0.9f); + // Pass the MaterialReader so each foliage asset resolves its atlas from A200+0x08 (a direct + // index into the 0x5200 texture table — see FoliageMetadata.TextureIndex). Textures are + // already loaded above (_textureShaderLoader.LoadAll), so OldTexturesByIndex is populated. + _foliageReader = new FoliageReader(_fileManager, _materialReader); + _foliages = _foliageReader.ReadAll(); + + // Old engine only (section 0x5920). Independent of geometry, same as foliage. + _cubemaps = new CubemapReader(_fileManager).ReadAll(); + + // Old-engine analytic lighting environment (section 0x8b00) - the game's real sun/ambient. + _lightingEnvironment = new LightingEnvironmentReader(_fileManager).Read(); + progressCallback?.Invoke("Loading remaining textures...", 0.95f); // Every texture the loader read from textures.dat/highmips.dat, not just the ones - // referenced by a shader actually used by the geometry above — see MaterialReader.GetAllTextures. + // referenced by a shader actually used by the geometry above - see MaterialReader.GetAllTextures. var allTextures = _materialReader.GetAllTextures(); progressCallback?.Invoke("Complete!", 1.0f); @@ -92,12 +113,18 @@ public LevelData LoadLevel(Action? progressCallback = null, bool shaders: _textureShaderLoader.Shaders, zoneLightmaps: _materialReader.WrapZoneLighting(_textureShaderLoader.ZoneLightmaps), zoneDirectionals: _materialReader.WrapZoneLighting(_textureShaderLoader.ZoneDirectionals), - environmentAverage: _textureShaderLoader.EnvironmentAverage); + environmentAverage: _textureShaderLoader.EnvironmentAverage, + foliages: _foliages, + cubemaps: _cubemaps, + lightingEnvironment: _lightingEnvironment); } public IReadOnlyDictionary Mobys => _mobys ?? []; public IReadOnlyDictionary Ties => _ties ?? []; public IReadOnlyDictionary Zones => _zones ?? []; + public IReadOnlyList Foliages => _foliages ?? []; + public IReadOnlyList Cubemaps => _cubemaps ?? []; + public Assets.Lighting.LightingEnvironment? LightingEnvironment => _lightingEnvironment; public Assets.Levels.Region? Region => _region; } @@ -113,13 +140,13 @@ public sealed class LevelData /// /// Every texture read from textures.dat/highmips.dat, including ones no loaded Moby/Tie/UFrag - /// material references — cut/unused textures aren't wired to any shader used by this level's + /// material references - cut/unused textures aren't wired to any shader used by this level's /// geometry, but are still worth being able to see/export (e.g. Hidden Palace-style datamining). /// public IReadOnlyDictionary AllTextures { get; } /// - /// Every shader the loader parsed from shaders.dat/main.dat, keyed by TUID — including ones + /// Every shader the loader parsed from shaders.dat/main.dat, keyed by TUID - including ones /// no loaded Moby/Tie/UFrag material references (same "cut content is still worth seeing" /// reasoning as AllTextures above). Raw, not the engine-facing IMaterial wrapper: this is /// meant for the Shader Browser, which exists specifically to inspect metadata (renderingMode @@ -129,16 +156,30 @@ public sealed class LevelData public IReadOnlyDictionary Shaders { get; } /// Baked light colour / light direction textures (main.dat sections 0x5400 / 0x5410), - /// POSITIONALLY indexed: entry X of each belongs to the instance whose lightmap index is X — + /// POSITIONALLY indexed: entry X of each belongs to the instance whose lightmap index is X - /// see TieInstance.LightmapIndex. The two lists always have equal length in real data. /// Empty on the new engine, whose pixel data lives in lighting.dat and isn't wired up. public IReadOnlyList ZoneLightmaps { get; } public IReadOnlyList ZoneDirectionals { get; } - /// Flat approximation of the level's environment cubemap — see + /// Flat approximation of the level's environment cubemap - see /// TextureShaderLoader.EnvironmentAverage. Null when the level has none. public System.Numerics.Vector3? EnvironmentAverage { get; } + /// Foliage card sets and their placements (main.dat 0xA200 / 0x9340). Empty on the new + /// engine, whose foliage sections are a different revision and aren't parsed. See + /// Loading.Objects.FoliageMetadata. + public IReadOnlyList Foliages { get; } + + /// Environment cubemap(s), old-engine section 0x5920 (see Loading.Readers.CubemapReader). + /// Usually one; empty when the level ships only a stub record (kerchu city) or on the new engine. + public IReadOnlyList Cubemaps { get; } + + /// The level's analytic lighting environment (old-engine section 0x8b00): the game's + /// real sun/ambient directions and colours. Null on the new engine or a level without it. See + /// Loading.Readers.LightingEnvironmentReader. + public Assets.Lighting.LightingEnvironment? LightingEnvironment { get; } + public LevelData( Dictionary mobys, Dictionary ties, @@ -150,7 +191,10 @@ public LevelData( IReadOnlyDictionary? shaders = null, IReadOnlyList? zoneLightmaps = null, IReadOnlyList? zoneDirectionals = null, - System.Numerics.Vector3? environmentAverage = null) + System.Numerics.Vector3? environmentAverage = null, + IReadOnlyList? foliages = null, + IReadOnlyList? cubemaps = null, + Assets.Lighting.LightingEnvironment? lightingEnvironment = null) { Mobys = mobys; Ties = ties; @@ -163,5 +207,8 @@ public LevelData( ZoneLightmaps = zoneLightmaps ?? []; ZoneDirectionals = zoneDirectionals ?? []; EnvironmentAverage = environmentAverage; + Foliages = foliages ?? []; + Cubemaps = cubemaps ?? []; + LightingEnvironment = lightingEnvironment; } } diff --git a/ReLunacy.Engine/Loading/Readers/LightingEnvironmentReader.cs b/ReLunacy.Engine/Loading/Readers/LightingEnvironmentReader.cs new file mode 100644 index 0000000..7429f61 --- /dev/null +++ b/ReLunacy.Engine/Loading/Readers/LightingEnvironmentReader.cs @@ -0,0 +1,64 @@ +using System.Numerics; +using ReLunacy.Engine.Loading.IO; + +namespace ReLunacy.Engine.Loading.Readers; + +/// Reads the old-engine analytic lighting environment (main.dat section 0x8b00). One 0x80 +/// record per level: three colour vectors at 0x20/0x30/0x40 and two unit light directions at +/// 0x50/0x60 (0x00 is a small int header, 0x10 and 0x70 are unused). See +/// for the field roles and the capture that pins +/// them. New engine is not handled. +public sealed class LightingEnvironmentReader +{ + public const uint ID = 0x8b00; + + private readonly FileManager _fileManager; + + public LightingEnvironmentReader(FileManager fileManager) + { + _fileManager = fileManager ?? throw new ArgumentNullException(nameof(fileManager)); + } + + public Assets.Lighting.LightingEnvironment? Read() + { + if (!_fileManager.isOld) return null; + if (!_fileManager.igfiles.TryGetValue("main.dat", out IGFile? main) || main is null) return null; + + var section = main.QuerySection(ID); + if (section.id != ID || section.length < 0x80) return null; + + Vector3 ReadVec3(uint offset) + { + main.sh.Seek(offset); + return new Vector3(main.sh.ReadSingle(), main.sh.ReadSingle(), main.sh.ReadSingle()); + } + + uint b = (uint)section.offset; + uint headerCount = main.sh.ReadUInt32(b); // first word = light count (2 on both levels seen) + + var env = new Assets.Lighting.LightingEnvironment { Ambient = ReadVec3(b + 0x20) }; + + // Ambient is colour[0] at 0x20; each directional light is colour[i] at 0x30/0x40 paired with + // direction[i] at 0x50/0x60. Build from whichever direction slots are actually populated + // rather than assuming two, so a level with fewer is handled (MaxLights is the record's + // physical capacity, not an assumption that every level fills it). + for (int i = 0; i < MaxLights; i++) + { + Vector3 dir = ReadVec3(b + 0x50 + (uint)i * 0x10); + if (dir.LengthSquared() <= 1e-8f) continue; + env.Lights.Add(new Assets.Lighting.DirectionalLight + { + Colour = ReadVec3(b + 0x30 + (uint)i * 0x10), + Direction = Vector3.Normalize(dir), + }); + } + + Console.WriteLine($"Lighting environment (0x8b00): headerCount={headerCount}, {env.Lights.Count} " + + $"directional light(s), ambient={env.Ambient}."); + return env; + } + + /// Directional lights the 0x80 record can physically hold (two direction slots). Not an + /// assumption that a level uses both - see the reader. + private const int MaxLights = 2; +} diff --git a/ReLunacy.Engine/Loading/Readers/MaterialReader.cs b/ReLunacy.Engine/Loading/Readers/MaterialReader.cs index ebdee19..8bf634f 100644 --- a/ReLunacy.Engine/Loading/Readers/MaterialReader.cs +++ b/ReLunacy.Engine/Loading/Readers/MaterialReader.cs @@ -14,6 +14,7 @@ public sealed class MaterialReader private readonly TextureShaderLoader _loader; private readonly Dictionary _materialCache = []; private readonly Dictionary _textureCache = []; + private readonly Dictionary _foliageMaterialCache = []; private Material? _defaultMaterial; public MaterialReader(TextureShaderLoader loader) @@ -32,9 +33,44 @@ public IMaterial GetMaterialForLocalIndex(ulong[]? shaderTuids, uint shaderIndex return GetDefaultMaterial(); } + /// Builds the material for an old-engine foliage asset from its direct texture index + /// ( — a physical position in the + /// 0x5200 table, resolved through , NOT + /// a shader lookup). Returns null for the 0xFFFFFFFF sentinel or an out-of-range index, so the + /// caller can fall back to the default billboard texture exactly as the game falls back. + /// + /// Foliage carries no shader reference of its own, so there is no ShaderMetadata to read a + /// render mode from; both metropolis foliage atlases are DXT5 and both foliage shaders are + /// RenderingMode.Blended, so the material is tagged AlphaBlend / Blended. The albedo texture is + /// wrapped through the same cache as every other texture, so the GPU upload is shared with any + /// other use of that same 0x5200 entry. + public IMaterial? GetFoliageMaterial(uint textureIndex) + { + if (_foliageMaterialCache.TryGetValue(textureIndex, out var cached)) + return cached; + + var legacy = _loader.ResolveOldTextureIndex(textureIndex); + if (legacy is null) + return null; + + var albedo = WrapTexture(legacy); + // Material id = the texture's own id (its 0x5200 record offset): unique per texture, so two + // foliage assets pointing at the same atlas share one material, and it can't collide with an + // old-engine shader TUID (those are small sequential indices — see Shader ctor). + var material = Material.Create( + id: albedo.Id, + albedo: albedo, + renderMode: RenderMode.AlphaBlend, + gameRenderMode: (byte)Loading.Shaders.RenderingMode.Blended); + material.Name = $"FoliageTexture_{textureIndex}"; + + _foliageMaterialCache[textureIndex] = material; + return material; + } + /// /// Wraps every texture the loader read from textures.dat/highmips.dat, not just the ones - /// referenced by a shader actually used by the currently loaded level's geometry — cut/unused + /// referenced by a shader actually used by the currently loaded level's geometry - cut/unused /// textures (interesting for datamining, e.g. Hidden Palace-style prototype content) never get /// touched by , since that only walks shaders reachable from /// loaded meshes. Reuses the same wrap cache, so nothing gets double-wrapped. @@ -76,14 +112,15 @@ public IMaterial GetMaterialByTuid(ulong tuid) properties: shader.Expensive != null ? WrapTexture(shader.Expensive) : null, detail: shader.DetailMap != null ? WrapTexture(shader.DetailMap) : null, renderMode: ToRenderMode(shader.RenderingMode), + // The game's own 0-6 mode, carried raw for renderers that implement its real RSX states + // (see IMaterial.GameRenderMode). Values outside 0-6 aren't render modes - clamp to Opaque. + gameRenderMode: (byte)shader.RenderingMode <= 6 ? (byte)shader.RenderingMode : (byte)0, alphaClipThreshold: GetAlphaClip(shader), - usesVertexAlphaCandidate: UsesVertexAlphaCandidate(shader.RenderingMode, albedo), + usesVertexAlphaCandidate: UsesVertexAlphaCandidate(shader.RenderingMode), + albedoHasAlphaChannel: HasAlphaChannel(albedo), parallaxScale: GetParallaxScale(shader), parallaxBias: GetParallaxBias(shader), detailTiling: GetDetailTiling(shader), - detailNormalStrength: shader.isOld ? shader.metadataOld!.Value.detailNormalStrength : 0f, - detailSpecStrength: shader.isOld ? shader.metadataOld!.Value.detailSpecStrength : 0f, - detailAlbedoStrength: shader.isOld ? shader.metadataOld!.Value.detailAlbedoStrength : 0f, usesDetailMap: UsesDetailMap(shader)); material.Name = shader.name; @@ -91,19 +128,11 @@ public IMaterial GetMaterialByTuid(ulong tuid) return material; } - // See TextureMetadataOld.AlphaKillCandidate — logged once per distinct texture so a real - // level load can show whether this bit actually correlates with textures that should be - // transparent but currently render solid. - private static readonly HashSet _loggedAlphaKillTextures = []; - private Texture WrapTexture(Textures.Texture legacy) { if (_textureCache.TryGetValue(legacy.id, out var cached)) return cached; - if (legacy.isOld && legacy.textureMetadata is Textures.TextureMetadataOld oldMeta && oldMeta.AlphaKillCandidate && _loggedAlphaKillTextures.Add(legacy.id)) - Console.WriteLine($"Diagnostic: texture {legacy.id:X} ('{legacy.name}') has the candidate old-engine alphaKill bit set (unverified — see TextureMetadataOld.AlphaKillCandidate)."); - var texture = Texture.FromData(legacy.id, legacy.Width, legacy.Height, ToTextureFormat(legacy.TexFormat), legacy.data, (int)legacy.MipmapCounts); texture.Name = legacy.name; _textureCache[legacy.id] = texture; @@ -127,14 +156,14 @@ private Material GetDefaultMaterial() // confirmed-appropriate treatment in this engine's simplified 4-case RenderMode; Scunge, // Soft-Edge, Blended, Baked Only, and Lit Only don't have independently confirmed blend // behavior yet, so they're conservatively mapped to the closest reasonable guess below rather - // than left to silently fall through — flagged per-case so it's easy to find and correct once + // than left to silently fall through - flagged per-case so it's easy to find and correct once // more is known about each. AssetManager already renders every material with // RasterizerStateDescription.CULL_NONE regardless of backface culling differences between // modes, so that distinction (if any of these have one) wouldn't currently change anything // downstream either way. // // The enum is believed exhaustive (all 9 values 0x00-0x08 accounted for), but the default - // case below stays defensive — logged once per distinct byte — in case something outside that + // case below stays defensive - logged once per distinct byte - in case something outside that // range shows up in a file this hasn't been checked against yet. private static readonly HashSet _loggedUnknownRenderingModes = []; @@ -146,33 +175,55 @@ private static RenderMode ToRenderMode(RenderingMode mode) case RenderingMode.Cutout: return RenderMode.AlphaClip; case RenderingMode.Overlay: return RenderMode.AlphaBlend; case RenderingMode.Additive: return RenderMode.Additive; - // Guess: "blended" implies alpha blend like Overlay above — stronger guess now that + // Guess: "blended" implies alpha blend like Overlay above - stronger guess now that // this is its own mode, not conflated with "baked only" anymore. case RenderingMode.Blended: return RenderMode.AlphaBlend; // Guess: "soft-edge" strongly suggests a depth-based edge fade (soft particles), which - // isn't implemented by anything downstream yet — treated as plain alpha blend for now, + // isn't implemented by anything downstream yet - treated as plain alpha blend for now, // which is at least not wrong about needing to blend, just incomplete about how. case RenderingMode.SoftEdge: return RenderMode.AlphaBlend; - // Guess: unclear semantics for all three — "baked"/"lit" read more like lighting - // qualifiers than transparency, so defaulting to Opaque is the conservative choice - // (risks looking solid when it should be transparent, not invisible/wrong-blended). - case RenderingMode.Scunge: return RenderMode.Opaque; + // Scunge is a real SRC_ALPHA/ONE_MINUS_SRC_ALPHA alpha blend with ZWrite off - confirmed by + // the EBOOT reverse (dev/chatgpt-eboot-2.txt: RenderingMode 3 -> queue 34 -> handler 0x51C350). + // Previously mapped to Opaque as a conservative guess, which rendered its glass/decals solid. + case RenderingMode.Scunge: return RenderMode.AlphaBlend; + // BakedOnly/LitOnly (0x07/0x08) aren't real render modes - the EBOOT's render-mode table + // stops at 6; they're debug/settings strings that leaked into the old guess. Treat as opaque. case RenderingMode.BakedOnly: return RenderMode.Opaque; case RenderingMode.LitOnly: return RenderMode.Opaque; default: byte raw = (byte)mode; if (_loggedUnknownRenderingModes.Add(raw)) - Console.WriteLine($"Warning: Unrecognized shader renderingMode byte 0x{raw:X2} (outside the believed-exhaustive 0x00-0x08 range — falling back to Opaque)."); + Console.WriteLine($"Warning: Unrecognized shader renderingMode byte 0x{raw:X2} (outside the believed-exhaustive 0x00-0x08 range - falling back to Opaque)."); return RenderMode.Opaque; } } + /// Old engine has NO alpha-clip threshold - it cuts at zero. ShaderMetadataOld's 0x20 + /// is not this field, and using it as one wrecked every cutout surface in the game. + /// + /// Cross-tabbed 0x20 against the renderingMode byte over metropolis's 631 old-engine shaders: + /// Cutout 14 shaders, alphaClip = 1.0 on ALL FOURTEEN, no exceptions + /// Opaque 426 at 1.0, 44 at a fraction (0.64, 0.80, 0.878, 0.902, ...) + /// SoftEdge 28 at 1.0, 14 at 0.0 + /// A clip threshold cannot be 1.0 on every single material that clips - that discards all but + /// perfectly opaque texels - and the fractional values land on OPAQUE materials, where a + /// threshold means nothing at all. Whatever 0x20 is (per-material opacity is the standing + /// suspicion, previously retracted for other reasons - see UsesVertexAlphaCandidate), it is + /// not this. Old engine therefore gets a zero threshold and the shader discards on `<=`. + /// + /// This is also the whole of the "blocky cutout edges" problem. Every one of those 14 Cutout + /// materials is DXT5, which stores alpha as two endpoints interpolated across a 4x4 block, so + /// demanding alpha == 1.0 exactly kept only the texels sitting at an endpoint - a mask aligned + /// to compression blocks. The edges were the DXT5 block grid, not a filtering artifact. + /// + /// New engine keeps reading its own field at 0x30; it has not been shown to have the same + /// problem, and inventing a zero there would be an unforced change. private static float GetAlphaClip(Shader shader) => - shader.isOld ? shader.metadataOld!.Value.alphaClip : shader.metadataNew!.Value.alphaClip; + shader.isOld ? 0f : shader.metadataNew!.Value.alphaClip; // ShaderMetadataOld 0x50/0x54, feeding the captured game shader's height * scale + bias. // Returned verbatim, sign included: which way relief appears to move is data, not something to - // correct here — if it comes out inverted the culprit is the tangent basis (see + // correct here - if it comes out inverted the culprit is the tangent basis (see // LitModelShaderSource's bitangent handedness), not this value. // The new engine's metadata has no identified equivalent, so it gets 0/0, which disables // parallax outright rather than substituting a made-up constant. The ShaderBrowser prints @@ -186,13 +237,13 @@ private static float GetParallaxBias(Shader shader) => /// Whether the material declares a detail map. Old engine reads the real feature flag /// (metadata 0x10, InsomniaToolset's MaterialV1_5.useDetailMap). The new engine has no - /// identified equivalent byte, so it falls back to "a detail texture is referenced" — the + /// identified equivalent byte, so it falls back to "a detail texture is referenced" - the /// engine-version split is resolved here, where isOld is known, rather than leaving consumers /// unable to tell a cleared flag from an absent one. private static bool UsesDetailMap(Shader shader) => shader.isOld ? shader.metadataOld!.Value.UsesDetailMap : shader.DetailMap != null; - // ShaderMetadataOld 0x58. Returned raw, including 0 — the consumer (AssetManager) is what + // ShaderMetadataOld 0x58. Returned raw, including 0 - the consumer (AssetManager) is what // decides that 0 means "no identified tiling, fall back to 1", because a tiling of literally // zero would collapse the whole detail map to a single texel and can't be what the field // means. Kept as a separate decision there so this stays a plain read of the file. @@ -200,18 +251,26 @@ private static float GetDetailTiling(Shader shader) => shader.isOld ? shader.metadataOld!.Value.detailTiling : 0f; // Per-material opacity (ShaderMetadata's decalOffsetCandidate/opacityCandidate at 0x48/0x4C) - // was retracted — it explained flat dimming but not the spatial fade actually seen in-game. + // was retracted - it explained flat dimming but not the spatial fade actually seen in-game. // The current best lead is per-vertex alpha (VertexFormat0.boneIndex, see PackedNormal-style - // decode on that field) — but the user suspects there's a shader-level enum somewhere that + // decode on that field) - but the user suspects there's a shader-level enum somewhere that // says whether a given mesh's ambiguous vertex field means bone index, vertex alpha, or vertex - // color (not yet found). Until that's identified, this is the one condition confirmed to - // correlate: a blending render mode with no albedo alpha to source transparency from. - private static bool UsesVertexAlphaCandidate(RenderingMode mode, ITexture? albedo) => - mode is RenderingMode.Overlay or RenderingMode.SoftEdge or RenderingMode.Blended && !HasAlphaChannel(albedo); - - // A1R5G5B5/RGBA4 carry real (if low-precision) alpha bits, same as A8R8G8B8/DXT3/DXT5 — - // included here for the same reason those are: UsesVertexAlphaCandidate should only kick in - // when the albedo genuinely has nowhere else to source transparency from. + // color (not yet found). + // Applies to every non-Opaque mode, not just a subset: Scunge and Additive blend just as much as + // Overlay/Soft-Edge/Blended do, and Cutout tests alpha, so all of them need somewhere to read it. + // Unlike the original version of this heuristic, it no longer requires the albedo to lack its + // own alpha channel: a transparent material's vertex alpha and its texture's alpha are not + // mutually exclusive sources (a decal with edge falloff baked into vertex colour can sit on a + // texture that already carries real alpha of its own) - see LitFragCommon.shade, which combines + // the two rather than picking one, using Material.AlbedoHasAlphaChannel to know whether the + // albedo's own alpha is meaningful enough to fold in. + private static bool UsesVertexAlphaCandidate(RenderingMode mode) => + mode != RenderingMode.Opaque; + + // A1R5G5B5/RGBA4 carry real (if low-precision) alpha bits, same as A8R8G8B8/DXT3/DXT5. Feeds + // Material.AlbedoHasAlphaChannel, which the shader uses to decide whether the albedo's own + // alpha is meaningful enough to fold into the final opacity alongside vertex alpha, or whether + // sampling .a would just be reading garbage from a format with no alpha channel at all. private static bool HasAlphaChannel(ITexture? texture) => texture?.Format is TextureFormat.A8R8G8B8 or TextureFormat.DXT3 or TextureFormat.DXT5 or TextureFormat.A1R5G5B5 or TextureFormat.RGBA4; diff --git a/ReLunacy.Engine/Loading/Readers/MobyReader.cs b/ReLunacy.Engine/Loading/Readers/MobyReader.cs index b3b90a2..2c17ba9 100644 --- a/ReLunacy.Engine/Loading/Readers/MobyReader.cs +++ b/ReLunacy.Engine/Loading/Readers/MobyReader.cs @@ -107,7 +107,7 @@ private Assets.Mobys.Moby ConvertMoby(Objects.Moby legacyMoby, ulong tuid) } /// Raw MobySkeleton (bone hierarchy + tms0/tms1 bind matrices, both engines share the - /// same layout — see MobySkeletonReader) into the clean IMoby-facing ISkeleton/IBone shape. + /// same layout - see MobySkeletonReader) into the clean IMoby-facing ISkeleton/IBone shape. /// Bones carry no name in this format, so they're indexed as "Bone_{i}". private static Assets.Interfaces.ISkeleton? ConvertSkeleton(MobySkeleton? raw) { @@ -153,7 +153,7 @@ private void ReadMobyBanglesMeshes(Objects.Moby moby) } // boneMapOffset is a header field on the mesh's own record, resolved via mobyStream (the - // same absolute-from-stream-start convention as skeletonPointer/banglesPointer) — NOT + // same absolute-from-stream-start convention as skeletonPointer/banglesPointer) - NOT // verticesStream/indicesStream, which only hold the bulk vertex/index buffer data. Read // defensively: new, unverified-against-every-real-asset code shouldn't be able to break // mesh loading for mobys that don't even have a skeleton to skin against. @@ -178,10 +178,13 @@ private IMesh ConvertMobyMesh(MobyMesh legacyMesh, Objects.Moby moby) { // Positions are fixed-point int16 in bangle-local space; the moby's own scale must be // applied here, matching what MobyMesh.GetBuffers already does for the legacy renderer. - legacyMesh.GetBuffers(moby.Scale, out var positions, out var indices, out var uvs, out var normals, out var tangents, out var vertexAlphaCandidates); + legacyMesh.GetBuffers(moby.Scale, out var positions, out var indices, out var uvs, out var normals, out var tangents); var (jointIndices, jointWeights) = ExtractSkinData(legacyMesh, (int)(moby.Skeleton?.NumBones ?? 0)); - var geometry = new GeometryData(id: 0, positions: positions, uvs: uvs, indices: indices, normals: normals, tangents: tangents, jointIndices: jointIndices, jointWeights: jointWeights, vertexAlphaCandidates: vertexAlphaCandidates); + // No vertexAlphaCandidates here: VertexFormat0.boneIndex is genuinely a bone index on Mobys + // (confirmed - it's the very field ExtractSkinData resolves above), not vertex alpha. That + // decode is only valid on Ties, which have no skeleton for the field to mean anything else. + var geometry = new GeometryData(id: 0, positions: positions, uvs: uvs, indices: indices, normals: normals, tangents: tangents, jointIndices: jointIndices, jointWeights: jointWeights); IMaterial material = moby.IsOld ? _materialReader.GetMaterialByIndex(legacyMesh.shaderIndex) @@ -192,12 +195,12 @@ private IMesh ConvertMobyMesh(MobyMesh legacyMesh, Objects.Moby moby) /// /// Resolves each vertex's raw bone reference(s) through this primitive's local joint palette - /// (mesh.boneMap) into skeleton-global bone indices + normalized weights — algorithm + /// (mesh.boneMap) into skeleton-global bone indices + normalized weights - algorithm /// transliterated from InsomniaToolset's extract_gltf.cpp (AttributeBoneIndex/ /// AttributeBoneIndices codecs), not independently derived: /// - VertexFormat1 (verticesType 1): 4 explicit (localIndex byte, weight byte) pairs. /// - VertexFormat0 (verticesType 0): a single implied full-weight binding, whose local palette - /// index is packed into the "purpose"/boneIndex int16 field as abs((purpose+1)/3) — the + /// index is packed into the "purpose"/boneIndex int16 field as abs((purpose+1)/3) - the /// toolset itself names that field "purpose", not "boneIndex", suggesting even its author /// wasn't fully certain of the encoding; flagged here as the least-confident piece of this /// feature. @@ -237,7 +240,7 @@ private static (int[]? jointIndices, float[]? jointWeights) ExtractSkinData(Moby } // skeletonBoneCount bounds-checks boneMap's resolved value too, not just the local palette - // index into boneMap itself — boneMap[localIndex] is a skeleton-global bone index, and nothing + // index into boneMap itself - boneMap[localIndex] is a skeleton-global bone index, and nothing // previously verified it was actually within the skeleton before it reached GltfExporter's // joint-node array (built with exactly skeleton.Bones.Count entries), where an out-of-range // value would throw. Treated the same as an unweighted slot (skipped) rather than clamped, so diff --git a/ReLunacy.Engine/Loading/Readers/RegionReader.cs b/ReLunacy.Engine/Loading/Readers/RegionReader.cs index 6ece5dd..c3ef4b2 100644 --- a/ReLunacy.Engine/Loading/Readers/RegionReader.cs +++ b/ReLunacy.Engine/Loading/Readers/RegionReader.cs @@ -51,7 +51,7 @@ private Assets.Levels.Region ReadRegionNew() } // New engine: gameplay.dat itself only carries a string table of region names (see - // Legacy's Gameplay class) — the actual moby/volume instance and zone-membership data + // Legacy's Gameplay class) - the actual moby/volume instance and zone-membership data // lives in a pair of per-region-named files (/gp_prius.dat and // /region.dat), loaded lazily here since FileManager only eagerly opens the // fixed top-level set. FileManager's suffix-based archive path resolution already handles @@ -67,7 +67,7 @@ private Assets.Levels.Region ReadRegionNew() // Every new-engine level observed so far has exactly one region ("default"); the // format supports more (see Legacy's Gameplay.regions array), but the data model here // (LevelData.Region, singular) doesn't yet. Load the first and flag the rest. - Console.WriteLine($"Level has {regionNames.Count} regions ({string.Join(", ", regionNames)}) — only '{regionNames[0]}' is currently loaded."); + Console.WriteLine($"Level has {regionNames.Count} regions ({string.Join(", ", regionNames)}) - only '{regionNames[0]}' is currently loaded."); } string regionName = regionNames[0]; @@ -96,7 +96,7 @@ private Assets.Levels.Region ReadRegionNew() /// /// Reads gameplay.dat's region-name string table (section 0x25000): its last 8 bytes are /// [regionCount, regionTableOffset], and regionTableOffset points to `regionCount` uint32 - /// name-string pointers — matches Legacy's Gameplay(AssetLoader) constructor exactly. + /// name-string pointers - matches Legacy's Gameplay(AssetLoader) constructor exactly. /// private static List ReadRegionNames(IGFile gameplay) { @@ -153,10 +153,11 @@ private List> ReadMobyInstancesOld(IGFile gameplayFile) // Matches Legacy's Region(IGFile, AssetLoader): debug.dat instance names (when // present) are matched purely by array position, not by any tuid. string name = _debugReader.GetMobyInstanceName(i) ?? $"Moby_{legacyInstance.mobyIndex:X4}_Instance_{i}"; - // 0 or negative in the file means unlimited — normalize to -1 so callers only + // 0 or negative in the file means unlimited - normalize to -1 so callers only // ever need to check "< 0 = unlimited". float displayDistance = legacyInstance.displayDist <= 0 ? -1f : legacyInstance.displayDist; - mobyInstances.Add(new PlacedInstance(moby, transform, (ulong)i, 0, name, displayDistance)); + float updateDistance = legacyInstance.updateDist <= 0 ? -1f : legacyInstance.updateDist; + mobyInstances.Add(new PlacedInstance(moby, transform, (ulong)i, 0, name, displayDistance, updateDistance)); } } @@ -166,7 +167,7 @@ private List> ReadMobyInstancesOld(IGFile gameplayFile) private List> ReadMobyInstancesNew(IGFile prius, IGFile region) { var mobyInstances = new List>(); - // Instances, their names, and volumes all live in gp_prius.dat — not region.dat, which + // Instances, their names, and volumes all live in gp_prius.dat - not region.dat, which // only carries the region-local moby-index lookup table and zone membership/names (see // Legacy's Region(AssetLoader, regionName) constructor). var mobyInstanceSection = prius.QuerySection(MobyInstanceNew.ID); @@ -175,7 +176,7 @@ private List> ReadMobyInstancesNew(IGFile prius, IGFile r return mobyInstances; // Moby prototypes are resolved through a *region-local* mobyIndex -> TUID lookup table in - // region.dat (section 0x1C600, 8 bytes/entry) — not the global assetlookup.dat pointer + // region.dat (section 0x1C600, 8 bytes/entry) - not the global assetlookup.dat pointer // table. Using the global table indexed the wrong prototypes (or found none at all). var mobyLookupSection = region.QuerySection(0x1C600); @@ -189,7 +190,7 @@ private List> ReadMobyInstancesNew(IGFile prius, IGFile r { metadatas[i] = new InstanceMetadata(prius.sh); // ReadString(offset) seeks absolutely into the string pool and leaves the stream - // there — capture the sequential position first and restore it after, or every + // there - capture the sequential position first and restore it after, or every // later iteration of this loop (and the seek-independent instance loop below) // silently reads from a drifted position instead of the next record. if (metadatas[i].namePointer != 0) @@ -226,7 +227,8 @@ private List> ReadMobyInstancesNew(IGFile prius, IGFile r : $"Moby_{legacyInstance.mobyIndex:X4}_Instance_{i}"; float displayDistance = legacyInstance.displayDist <= 0 ? -1f : legacyInstance.displayDist; - mobyInstances.Add(new PlacedInstance(moby, transform, instanceTUID, group, name, displayDistance)); + float updateDistance = legacyInstance.updateDist <= 0 ? -1f : legacyInstance.updateDist; + mobyInstances.Add(new PlacedInstance(moby, transform, instanceTUID, group, name, displayDistance, updateDistance)); } } @@ -247,14 +249,14 @@ private static List ReadVolumesOld(IGFile gameplayFile, DebugReader debu { // Old-engine volume entries are 0x90 bytes each: a 0x40-byte (16-float, row-major, // same convention as TieBound/new-engine volumes) transform matrix followed by 0x50 - // bytes of still-unidentified trailing data — confirmed against ReLunacy-Ymir's own + // bytes of still-unidentified trailing data - confirmed against ReLunacy-Ymir's own // OldVolumeInstance, which documents this exact layout and explicitly warns against // reading it as a packed array of bare matrices. Reading with no stride skip (what - // this used to do — sequential 0x40-byte reads with no gap) meant every entry after + // this used to do - sequential 0x40-byte reads with no gap) meant every entry after // the first started inside the PREVIOUS entry's unknown trailing bytes instead of at // its own real matrix: since gcd(0x40, 0x90) leaves a common period of 9 iterations // (9 * 0x40 == 4 * 0x90), only every 9th "volume" happened to land back on a genuine - // entry boundary and decode correctly — everything else decomposed into a garbled + // entry boundary and decode correctly - everything else decomposed into a garbled // scale/rotation, which reads as a visibly wrong-shaped/wrong-proportioned volume. gameplayFile.sh.Seek(volumeSection.offset + i * 0x90); string name = debugReader.GetVolumeName(i) ?? $"Volume_{i}"; diff --git a/ReLunacy.Engine/Loading/Readers/TieReader.cs b/ReLunacy.Engine/Loading/Readers/TieReader.cs index 30b4792..dafa924 100644 --- a/ReLunacy.Engine/Loading/Readers/TieReader.cs +++ b/ReLunacy.Engine/Loading/Readers/TieReader.cs @@ -33,7 +33,7 @@ public TieReader(FileManager fileManager, MaterialReader materialReader, DebugRe { var legacyTie = new Objects.Tie(main, _fileManager, old: true, index: i); - // Old engine: TieInstance.tieIndex stores file offsets, not sequential indices — + // Old engine: TieInstance.tieIndex stores file offsets, not sequential indices - // key ties the same way to match. ulong key = tieSection.offset + i * TieMetadataOld.Size; ties.Add(key, ConvertTie(legacyTie, key)); @@ -68,7 +68,7 @@ public TieReader(FileManager fileManager, MaterialReader materialReader, DebugRe var legacyTie = new Objects.Tie(new IGFile(tiems), _fileManager, old: false); // Keyed by the assetlookup pointer-table TUID, not the tie's own embedded TUID field - // (0x68) — matches MobyReader/ZoneReader's pattern and the legacy AssetLoader, since + // (0x68) - matches MobyReader/ZoneReader's pattern and the legacy AssetLoader, since // the embedded field isn't reliably unique (observed colliding at 0 across records). ties.Add(tiePtrs[i].TUID, ConvertTie(legacyTie, tiePtrs[i].TUID)); @@ -85,15 +85,33 @@ private Assets.Ties.Tie ConvertTie(Objects.Tie legacyTie, ulong id) var scaleVec = legacyTie.Scale; var meshes = new List(); + + // Validate the lightmap UVs mesh by mesh (see SliceLightmapUVs) and rebuild the tie-wide + // array from only the windows that pass, so ITie.GetLightmapUVs stays consistent with what + // the meshes actually got. Meshes without a usable window keep zeros there - the same thing + // an unshaded mesh's slot holds in the file - and null means no mesh had one at all, which + // is what EntityTie tests before binding a bake. + float[]? tieLightmapUVs = null; + for (int i = 0; i < legacyTie.MeshesCount; i++) + { + ref TieMesh m = ref legacyTie.Meshes[i]; + var slice = SliceLightmapUVs(legacyTie.LightmapUVs, m.verticesIndex, m.verticesCount); + if (slice is null || !LooksLikeUnwrap(m, slice)) continue; + + tieLightmapUVs ??= new float[legacyTie.LightmapUVs!.Length]; + Array.Copy(slice, 0, tieLightmapUVs, m.verticesIndex * 2, slice.Length); + } + for (int i = 0; i < legacyTie.MeshesCount; i++) { - meshes.Add(ConvertTieMesh(legacyTie.Meshes[i], scaleVec, legacyTie)); + meshes.Add(ConvertTieMesh(legacyTie.Meshes[i], scaleVec, legacyTie, tieLightmapUVs)); } var debugName = _debugReader.GetTiePrototypeName(legacyTie.TUID); var name = debugName ?? (!string.IsNullOrEmpty(legacyTie.Name) ? legacyTie.Name : $"Tie_{id:X}"); - return new Assets.Ties.Tie(id: id, meshes: meshes, scale: 1.0f, name: name); // per-axis scale already applied during mesh conversion + // per-axis scale already applied during mesh conversion + return new Assets.Ties.Tie(id: id, meshes: meshes, scale: 1.0f, name: name, lightmapUVs: tieLightmapUVs); } private void ReadTieMeshes(Objects.Tie tie) @@ -113,11 +131,90 @@ private void ReadTieMeshes(Objects.Tie tie) } } - private IMesh ConvertTieMesh(TieMesh legacyMesh, System.Numerics.Vector3 scale, Objects.Tie tie) + /// Rejected below this: a mesh's UV window has to actually behave like an unwrap of + /// THIS mesh, not merely decode in range. Measured over metropolis's 494 testable tie meshes the + /// score's median is 0.874, so 0.5 sits far below the real population and cuts 16% - the windows + /// that pass the range check by luck and would otherwise repeat the bake across the surface. + private const double MinUnwrapCorrelation = 0.5; + + /// Below this many usable triangles the correlation is noise, so the mesh is accepted + /// untested rather than discarded. That covers 1329 small meshes on metropolis (34,764 vertices + /// total) - the remaining blind spot, and the first place to look if stray ties still show a + /// doubled bake. + private const int MinTrianglesToTest = 40; + + /// Does this UV window unwrap this mesh? A lightmap packer allocates texels roughly in + /// proportion to world-space surface area, so per triangle log(UV area) tracks log(3D area). + /// Real windows score ~0.87 here; a random in-range window from elsewhere in the vertex blob + /// scores ~0.0, so this is the test that separates them - the [0,1] range check alone does not + /// (see Loading.Vertices.TieLightmapUV, which documents why several other natural metrics here + /// are vacuous). + /// + /// Note what this canNOT do: it does not distinguish a lightmap unwrap from an ALBEDO unwrap. + /// Base UVs score ~0.64 by themselves, because artists unwrap those with roughly uniform texel + /// density too. It only separates real-for-this-mesh from unrelated bytes, which is exactly the + /// failure being screened out here. + /// + /// Uses raw int16 positions: any per-axis scale is a constant factor inside the logarithm and + /// shifts the intercept, not the correlation. + private static bool LooksLikeUnwrap(in TieMesh mesh, float[] uvs) + { + var verts = mesh.vertices; + var indices = mesh.indices; + if (verts is null || indices is null) return true; + + int vertexCount = mesh.verticesCount; + double sx = 0, sy = 0, sxx = 0, syy = 0, sxy = 0; + int n = 0; + + for (int t = 0; t + 2 < indices.Length; t += 3) + { + int i0 = indices[t], i1 = indices[t + 1], i2 = indices[t + 2]; + if (i0 >= vertexCount || i1 >= vertexCount || i2 >= vertexCount) return true; + + var (ax, ay, az) = verts[i0].position; + var (bx, by, bz) = verts[i1].position; + var (cx, cy, cz) = verts[i2].position; + + double e1x = bx - ax, e1y = by - ay, e1z = bz - az; + double e2x = cx - ax, e2y = cy - ay, e2z = cz - az; + double nx = e1y * e2z - e1z * e2y; + double ny = e1z * e2x - e1x * e2z; + double nz = e1x * e2y - e1y * e2x; + double area3 = 0.5 * Math.Sqrt(nx * nx + ny * ny + nz * nz); + if (area3 <= 1e-6) continue; + + double u0 = uvs[i0 * 2], v0 = uvs[i0 * 2 + 1]; + double du1 = uvs[i1 * 2] - u0, dv1 = uvs[i1 * 2 + 1] - v0; + double du2 = uvs[i2 * 2] - u0, dv2 = uvs[i2 * 2 + 1] - v0; + double area2 = 0.5 * Math.Abs(du1 * dv2 - du2 * dv1); + if (area2 <= 1e-12) continue; + + double x = Math.Log(area3), y = Math.Log(area2); + sx += x; sy += y; sxx += x * x; syy += y * y; sxy += x * y; + n++; + } + + if (n < MinTrianglesToTest) return true; + + double cov = sxy - sx * sy / n; + double varX = sxx - sx * sx / n; + double varY = syy - sy * sy / n; + if (varX <= 0 || varY <= 0) return true; + + return cov / Math.Sqrt(varX * varY) >= MinUnwrapCorrelation; + } + + private IMesh ConvertTieMesh(TieMesh legacyMesh, System.Numerics.Vector3 scale, Objects.Tie tie, float[]? tieLightmapUVs) { legacyMesh.GetBuffers(scale, out var positions, out var indices, out var uvs, out var normals, out var tangents, out var vertexAlphaCandidates); - var geometry = new GeometryData(id: 0, positions: positions, uvs: uvs, indices: indices, normals: normals, tangents: tangents, vertexAlphaCandidates: vertexAlphaCandidates); + // The tie's lightmap UV array is indexed over its WHOLE vertex buffer, so each mesh takes + // the window starting at its own verticesIndex (see ITie.GetLightmapUVs). tieLightmapUVs is + // already the validated array, so this slice can't fail the range check a second time. + var lightmapUVs = SliceLightmapUVs(tieLightmapUVs, legacyMesh.verticesIndex, legacyMesh.verticesCount); + + var geometry = new GeometryData(id: 0, positions: positions, uvs: uvs, indices: indices, normals: normals, tangents: tangents, vertexAlphaCandidates: vertexAlphaCandidates, lightmapUVs: lightmapUVs); IMaterial material = legacyMesh.isOld ? _materialReader.GetMaterialByIndex(legacyMesh.oldShaderIndex) @@ -125,4 +222,38 @@ private IMesh ConvertTieMesh(TieMesh legacyMesh, System.Numerics.Vector3 scale, return new Assets.Geometry.Mesh(geometry, material, "TieMesh", TieMesh.VertexFormatName, legacyMesh.DumpVertex); } + + /// Copies out one mesh's window of the tie-wide lightmap UV array, or null if this mesh + /// has no usable one - in which case the mesh renders unlit while its siblings still light. + /// + /// THE VALIDITY CHECK IS PER MESH, and that is the whole point. Objects.Tie hands back the raw + /// bytes at metadata 0x18 without judging them; a mesh's window starts at verticesIndex * 4 + /// inside that. On metropolis 2082 of 3771 tie meshes hold real UV data there but only 61 of 193 + /// ties hold it for ALL of their meshes, so validating tie-wide discards 112 partly-baked ties - + /// including every tie carrying one of the level's 256x256 lightmaps. See + /// Loading.Vertices.TieLightmapUV. + /// + /// The test is just "every half decodes inside [0,1]". At an unknown offset that is nearly + /// vacuous, but at this fixed one it does the job: the windows that pass score a median 0.870 on + /// the area-preservation test that actually proves the decode, versus ~0.0 for matched random + /// windows. A short or out-of-bounds window returns null rather than a partial copy, since that + /// would silently shift every subsequent vertex's UV. + private static float[]? SliceLightmapUVs(float[]? tieLightmapUVs, ushort verticesIndex, ushort verticesCount) + { + if (tieLightmapUVs is null || verticesCount == 0) return null; + + int start = verticesIndex * 2; + int count = verticesCount * 2; + if (start < 0 || start + count > tieLightmapUVs.Length) return null; + + for (int i = start; i < start + count; i++) + { + float f = tieLightmapUVs[i]; + if (!float.IsFinite(f) || f < 0f || f > 1f) return null; + } + + var slice = new float[count]; + Array.Copy(tieLightmapUVs, start, slice, 0, count); + return slice; + } } diff --git a/ReLunacy.Engine/Loading/Readers/ZoneReader.cs b/ReLunacy.Engine/Loading/Readers/ZoneReader.cs index dea4a63..956d131 100644 --- a/ReLunacy.Engine/Loading/Readers/ZoneReader.cs +++ b/ReLunacy.Engine/Loading/Readers/ZoneReader.cs @@ -109,7 +109,7 @@ private List ReadUFrags(Objects.Zone legacyZone, ulong[]? shaderTuids) for (int i = 0; i < ufragSection.count; i++) { // Seek absolutely to each record's start rather than relying on wherever the - // constructor's last internal Seek() happened to leave the stream — UFragMetadata's + // constructor's last internal Seek() happened to leave the stream - UFragMetadata's // reads are scattered (non-monotonic), so a relative Position += Size advancement // doesn't reliably land on the next record. legacyZone.zoneStream.Seek(ufragSection.offset + (long)i * UFragMetadata.Size); @@ -141,7 +141,7 @@ private IUFrag ConvertUFrag(UFrag legacyUFrag, ulong id, ulong[]? shaderTuids) var positions = legacyUFrag.vpos; var uvs = legacyUFrag.uvs; // legacyUFrag.indices is the raw ArrayPool-rented buffer, whose Length is only guaranteed - // to be >= metadata.indexCount (rounds up to the pool's bucket size) — trim to the real + // to be >= metadata.indexCount (rounds up to the pool's bucket size) - trim to the real // count so stale data from a previous tenant of that buffer doesn't leak in as bogus, // wildly out-of-range indices (same class of bug as the vpos/uvs sizing fix in UFrag.cs). var indices = legacyUFrag.indices.AsSpan(0, (int)legacyUFrag.metadata.indexCount).ToArray(); @@ -149,7 +149,7 @@ private IUFrag ConvertUFrag(UFrag legacyUFrag, ulong id, ulong[]? shaderTuids) // Real per-vertex normals/tangents, decoded from the same packed 11:11:10 words // VertexFormat0/1 use (see UFrag.ReadVertices). Handedness (tangent W) is derived from UV // gradients exactly like the Moby/Tie path does (GeometryData -> GeometryMath), since the - // packed word carries none — ComputeTangents keeps the real decoded xyz and only adds W. + // packed word carries none - ComputeTangents keeps the real decoded xyz and only adds W. var normals = legacyUFrag.normals; var tangents = GeometryMath.ComputeTangents(positions, uvs, normals, indices, legacyUFrag.tangents); @@ -158,46 +158,46 @@ private IUFrag ConvertUFrag(UFrag legacyUFrag, ulong id, ulong[]? shaderTuids) : _materialReader.GetMaterialForLocalIndex(shaderTuids, legacyUFrag.metadata.shaderIndex); // Two distinct concepts, previously conflated (both read from the same 0x30 field in the - // metadata): `anchor` is the placement translation — local (0,0,0) of `positions` maps - // there — while `boundingCenter`/`boundingRadius` is the true bounding sphere, used only + // metadata): `anchor` is the placement translation - local (0,0,0) of `positions` maps + // there - while `boundingCenter`/`boundingRadius` is the true bounding sphere, used only // for culling and never for placement. Vector3 anchor, boundingCenter; float boundingRadius; if (legacyUFrag.isOld) { - // Old engine's real placement anchor hasn't been located yet — position and - // boundingSphere share the same fixed-point ×256 field, so anchor and boundingCenter - // coincide here, same as before this split existed. - var rawCenter = new Vector3(legacyUFrag.metadata.boundingSphere.X, legacyUFrag.metadata.boundingSphere.Y, legacyUFrag.metadata.boundingSphere.Z); - anchor = rawCenter / 256f; - boundingCenter = anchor; - // Radius isn't reliably decodable from this field for old-engine UFrags; fixed - // fallback matches the last confirmed-working implementation (see EntityUFrag). - boundingRadius = 2.5f; + // Anchor comes from the fixed-point x256 field at 0x60; the bounding sphere is its own + // field at 0x30/0x3C and is already world-space (see UFragMetadata, which verifies the + // radius against each UFrag's own vertices). These are no longer the same field, so the + // radius is real instead of the 2.5f constant every old UFrag used to get - that + // constant under-reported chunks up to 89 units across and culled them far too early. + anchor = legacyUFrag.metadata.position / 256f; + boundingCenter = new Vector3(legacyUFrag.metadata.boundingSphere.X, legacyUFrag.metadata.boundingSphere.Y, legacyUFrag.metadata.boundingSphere.Z); + boundingRadius = legacyUFrag.metadata.boundingSphere.W; } else { - // The chunk's real placement anchor is `metadata.anchor` (0x70), fixed-point ×256 - // like old engine's own position field — NOT `boundingSphere.XYZ` (0x30), which is a + // The chunk's real placement anchor is `metadata.anchor` (0x70), fixed-point x256 + // like old engine's own position field - NOT `boundingSphere.XYZ` (0x30), which is a // genuine, non-grid-aligned bounding-sphere centroid. Using the centroid as the - // translation (as this used to) introduced a per-chunk sub-unit placement error — + // translation (as this used to) introduced a per-chunk sub-unit placement error - // confirmed by dumping every UFrag in a zone: metadata.anchor is always an exact // integer while boundingSphere.XYZ never is. boundingSphere.XYZ/.W is a genuine, - // already-world-space bounding sphere (no ×256 decoding needed) — kept for culling. + // already-world-space bounding sphere (no x256 decoding needed) - kept for culling. anchor = legacyUFrag.metadata.newEnginePos / 256f; boundingCenter = new Vector3(legacyUFrag.metadata.boundingSphere.X, legacyUFrag.metadata.boundingSphere.Y, legacyUFrag.metadata.boundingSphere.Z); boundingRadius = legacyUFrag.metadata.boundingSphere.W; } - // Lightmap UVs are only meaningful alongside a lightmap index — a second UV set with + // Lightmap UVs are only meaningful alongside a lightmap index - a second UV set with // nothing to sample is just wasted vertex bandwidth, and passing it anyway would make // "has lightmap UVs" stop implying "is lightmapped" for every consumer downstream. var lightmapIndex = legacyUFrag.metadata.lightmapIndex; var lightmapUVs = legacyUFrag.metadata.HasLightmap && legacyUFrag.uvs2.Length > 0 ? legacyUFrag.uvs2 : null; + var vertexAlphaCandidates = legacyUFrag.vertexAlphaCandidates; return legacyUFrag.isOld - ? new OldUFrag(id: id, positions: positions, uvs: uvs, indices: indices, material: material, anchor: anchor, boundingCenter: boundingCenter, boundingRadius: boundingRadius, normals: normals, tangents: tangents, lightmapUVs: lightmapUVs, lightmapIndex: lightmapIndex, metadata: legacyUFrag.metadata) - : new NewUFrag(id: id, positions: positions, uvs: uvs, indices: indices, material: material, anchor: anchor, boundingCenter: boundingCenter, boundingRadius: boundingRadius, normals: normals, tangents: tangents, lightmapUVs: lightmapUVs, lightmapIndex: lightmapIndex, metadata: legacyUFrag.metadata); + ? new OldUFrag(id: id, positions: positions, uvs: uvs, indices: indices, material: material, anchor: anchor, boundingCenter: boundingCenter, boundingRadius: boundingRadius, normals: normals, tangents: tangents, lightmapUVs: lightmapUVs, vertexAlphaCandidates: vertexAlphaCandidates, lightmapIndex: lightmapIndex, metadata: legacyUFrag.metadata) + : new NewUFrag(id: id, positions: positions, uvs: uvs, indices: indices, material: material, anchor: anchor, boundingCenter: boundingCenter, boundingRadius: boundingRadius, normals: normals, tangents: tangents, lightmapUVs: lightmapUVs, vertexAlphaCandidates: vertexAlphaCandidates, lightmapIndex: lightmapIndex, metadata: legacyUFrag.metadata); } private List> ReadTieInstances(Objects.Zone legacyZone) @@ -229,7 +229,7 @@ private List> ReadTieInstances(Objects.Zone legacyZone) } // New engine: tie instance names live in the zone's own file (section 0x72C0), - // positionally matched to the instance array — matches Legacy's CZone constructor. + // positionally matched to the instance array - matches Legacy's CZone constructor. if (legacyZone.tieNameSection.count > 0) { legacyZone.zoneStream.Seek(legacyZone.tieNameSection.offset); @@ -261,7 +261,7 @@ private List> ReadTieInstances(Objects.Zone legacyZone) // Pass the raw matrix directly to avoid a lossy decompose-recompose round trip. // LightmapIndex: this instance's baked light colour + direction pair. Old engine - // only — see TieInstance.LightmapIndex for the measurements behind the offset. + // only - see TieInstance.LightmapIndex for the measurements behind the offset. var placedInstance = new PlacedInstance(tie, legacyInstance.transform, (ulong)i, 0, name) { LightmapIndex = legacyZone.isOld ? legacyInstance.LightmapIndex : TieInstance.NoLightmap, diff --git a/ReLunacy.Engine/Loading/Shaders/RenderingMode.cs b/ReLunacy.Engine/Loading/Shaders/RenderingMode.cs index e2f7992..d7d424b 100644 --- a/ReLunacy.Engine/Loading/Shaders/RenderingMode.cs +++ b/ReLunacy.Engine/Loading/Shaders/RenderingMode.cs @@ -1,19 +1,19 @@ namespace ReLunacy.Engine.Loading.Shaders; // Full byte->mode mapping, as reported by the user after independent investigation (all 9 values, -// superseding every earlier guess in this file's history — notably that 0x01 was "Decal": it's +// superseding every earlier guess in this file's history - notably that 0x01 was "Decal": it's // actually a general-purpose Overlay blend mode, decals are just one of the things it's used for, // which is exactly why some 0x01-shaded meshes never needed the Z-fight vertex offset this project -// tried and later retracted entirely (the game doesn't decal-offset vertices at all — see -// ShaderMetadata's now-unknown 0x48 field) — they were never decals to begin with, just unrelated +// tried and later retracted entirely (the game doesn't decal-offset vertices at all - see +// ShaderMetadata's now-unknown 0x48 field) - they were never decals to begin with, just unrelated // overlay-blended surfaces. 0x05/0x06 are NOT "with/without backface culling" as previously // guessed either; they're distinct named modes (Soft-Edge vs. Blended). 0x06/0x07 were originally -// reported as one combined "Blended Baked Only" mode — corrected to two distinct modes, Blended +// reported as one combined "Blended Baked Only" mode - corrected to two distinct modes, Blended // (0x06) and Baked Only (0x07), which pushed the original 0x07 Lit Only up to 0x08. // // Only Opaque/Cutout have a well-understood render treatment right now. The exact blend/lighting // behavior for Scunge, Soft-Edge, Blended, Baked Only, and Lit Only isn't independently confirmed -// against this engine's rendering — see MaterialReader.ToRenderMode for current (conservative) +// against this engine's rendering - see MaterialReader.ToRenderMode for current (conservative) // mapping choices and where that's still a guess. public enum RenderingMode : byte { diff --git a/ReLunacy.Engine/Loading/Shaders/Shader.cs b/ReLunacy.Engine/Loading/Shaders/Shader.cs index 1fa5108..3913f38 100644 --- a/ReLunacy.Engine/Loading/Shaders/Shader.cs +++ b/ReLunacy.Engine/Loading/Shaders/Shader.cs @@ -18,12 +18,41 @@ public class Shader public Texture? Albedo; public Texture? Normal; + // Insomniac's "expensive" map - four unrelated masks packed into one texture. Channel roles + // read off the captured shaders, which agree across all six programs dumped so far (one UFrag + // program in dev/, five tie programs in dev/ties/): + // R = gloss / specular mask multiplies the specular term + // G = PARALLAX HEIGHT fed to fma(h, parallaxScale, parallaxBias) + // B = additive emissive added to the baked light colour + // A = detail-map mask "an optional channel in the standard shader template that + // functions as a detail map mask - it modulates the intensity + // with which the detail map is applied" (WWS post-mortem) + // G is what pins ShaderMetadataOld.UsesParallax: in the three programs that do parallax, tex2 + // is sampled twice (once at the raw UV for the height, once at the offset UV) and .y is read; + // in the three that don't, tex2 is sampled once and .y is never touched. 6/6, no exceptions. + // NOTE: GltfExporter.ApplyExpensiveChannels still uses the older R=spec / G=metallic / + // B=emissive split. R and B survive that revision; G does not. public Texture? Expensive; - // Old engine only so far (ShaderMetadataOld.detailMap, offset 0x0C) — ShaderMetadataNew - // hasn't had its equivalent identified yet. Confirmed layout: B = roughness, R/G = a second, - // higher-frequency tangent-space normal map, sampled at a tiled UV. The tiling scale itself - // hasn't been located in ShaderMetadata's still-unidentified byte ranges — consumers - // (GltfExporter) use a placeholder constant until it's found. + // Old engine only so far (ShaderMetadataOld.detailMap, offset 0x0C) - ShaderMetadataNew + // hasn't had its equivalent identified yet. + // + // Channel layout, stated outright in Insomniac's own post-mortem for this game + // (dev/Ratchet_and_Clank_WWS_Debrief_Feb_08.pdf, "Improved Detail Maps"): four channels, DXT5 + // or ARGB8888, holding a colour map offset, a gloss map offset, and the two partial derivatives + // of a normal map delta. The captured UFrag fragment shader pins which channel is which, by + // where each one lands: + // R = normal delta dx ] added to the base normal map's .x/.y, scaled by fc[4].xy + // G = normal delta dy ] (ShaderMetadataOld.detailNormalStrength) + // B = COLOUR offset added to albedo, scaled by fc[5].z (detailAlbedoStrength) + // A = GLOSS offset added to the tex2 gloss term, scaled by fc[5].w (detailSpecStrength, + // a misnomer kept only because the name is threaded through IMaterial) + // Both offsets are SIGNED, -1..+1 - the game gets that via the RSX texture remap, so anything + // decoding this map itself has to apply the same bias rather than read it as unorm. + // This supersedes the earlier "B = roughness, R/G = a second normal map" reading: R/G were + // right, but B is a colour offset and the gloss offset in A was missed entirely. + // + // Sampled at a tiled UV; the tiling scale is ShaderMetadataOld.detailTiling (0x58), with + // AssetManager.DefaultDetailTiling standing in only when the field reads a literal zero. public Texture? DetailMap; public RenderingMode RenderingMode => (RenderingMode)(isOld ? metadataOld!.Value.renderingMode : metadataNew!.Value.renderingMode); diff --git a/ReLunacy.Engine/Loading/Shaders/ShaderMetadata.cs b/ReLunacy.Engine/Loading/Shaders/ShaderMetadata.cs index 8f2c7ca..f082dc8 100644 --- a/ReLunacy.Engine/Loading/Shaders/ShaderMetadata.cs +++ b/ReLunacy.Engine/Loading/Shaders/ShaderMetadata.cs @@ -14,22 +14,37 @@ public record struct ShaderMetadataOld : ILunaSerializable [FileOffset(0x04)] public uint normal; [FileOffset(0x08)] public uint expensive; [FileOffset(0x0C)] public uint detailMap; - // Material feature flags. Identified from InsomniaToolset's MaterialV1_5 (shader.hpp, same - // section ID 0x5000), whose field layout maps onto this struct offset-for-offset: four texture - // references at 0x00-0x0F, this flags byte at 0x10, blendMode (our renderingMode) at 0x11. - // Its declared bits are, in order: unkFlag, useSpecular, useGlossiness, useNormalMap, + // Material feature flags. Field POSITION identified from InsomniaToolset's MaterialV1_5 + // (shader.hpp, same section ID 0x5000), whose layout maps onto this struct offset-for-offset: + // four texture references at 0x00-0x0F, this flags byte at 0x10, blendMode (our renderingMode) + // at 0x11. Its declared bits are, in order: unkFlag, useSpecular, useGlossiness, useNormalMap, // useDetailMap, then 3 spare. + // + // The NAMES are Insomniac's own, and "useSpecular" is wrong. Their WWS post-mortem for this + // exact game (dev/Ratchet_and_Clank_WWS_Debrief_Feb_08.pdf, "Shader Usage Controls") lists the + // toggles they shipped as: "Which attributes (NORMAL, GLOSS, PARALLAX, DETAIL MAP) are disabled + // for this use of the shader." Four attributes, and specular is not among them - parallax is. + // Gloss, normal and detail map all line up with the other three bits, so the odd one out is the + // one InsomniaToolset guessed at. Hence UsesParallax below. + // + // Same source explains WHY this byte exists at all: there is ONE "standard shader template", + // and every material is that template with some attributes switched off. So these bits are not + // decoration - they are the permutation key, and a renderer that honours them reproduces the + // game's material variants without needing a shader per variant. [FileOffset(0x10)] public byte flags; // Bit positions assume the least-significant-first allocation InsomniaToolset's own (x86) // build uses for that bitfield. Byte-swapping doesn't affect a single byte, so the VALUE here - // is exactly what the file holds either way — only the direction of the bit walk is a + // is exactly what the file holds either way - only the direction of the bit walk is a // convention, and it flips if the fields were packed most-significant-first instead. // Cheap to verify rather than reason about: the ShaderBrowser prints this byte raw alongside // the decoded flags, so on any level exactly one bit will track "this material has a detail // texture". If UsesDetailMap disagrees with DetailMap being non-null across materials, the // walk is reversed and these shift to 7-minus. - public readonly bool UsesSpecular => (flags & 0x02) != 0; + // The same trick pins UsesParallax independently: this struct already carries parallaxScale at + // 0x50, so on any level this bit should track parallaxScale != 0. If it tracks something else, + // the parallax toggle is one of the other bits (unkFlag at 0x01 being the obvious alternative). + public readonly bool UsesParallax => (flags & 0x02) != 0; public readonly bool UsesGlossiness => (flags & 0x04) != 0; public readonly bool UsesNormalMap => (flags & 0x08) != 0; public readonly bool UsesDetailMap => (flags & 0x10) != 0; @@ -40,37 +55,35 @@ public record struct ShaderMetadataOld : ILunaSerializable // 0x20..0x7F is SIX 16-byte vectors, not loose floats. From InsomniaToolset's MaterialV1_5: // the header ends at 0x14, Vector4A16 forces 16-byte alignment so the array starts at 0x20, // and 6 * 16 = 0x60 lands exactly on 0x80. That is a hard constraint on any future field - // identified in here — a float must sit at offset 0x0/0x4/0x8/0xC within its own vector, and + // identified in here - a float must sit at offset 0x0/0x4/0x8/0xC within its own vector, and // values belonging to one logical group will usually share a vector rather than straddle two: // values[0] 0x20 values[1] 0x30 values[2] 0x40 // values[3] 0x50 values[4] 0x60 values[5] 0x70 - // Known so far: alphaClip = values[0].x; parallaxScale/Bias/detailTiling = values[3].x/.y/.z - // (a clean, contiguous group, which is itself evidence for those three). The detail strengths - // below are the shakiest: at 0x28/0x2C/0x30 they straddle values[0] into values[1], whereas - // the alternative 0x24/0x28/0x2C keeps all three inside values[0]. The captured shader has - // detailAlbedoStrength and detailSpecStrength adjacent within ONE vector (fc[5].z and fc[5].w), - // which leans toward the second reading — see the note on Unk2a. + // CONFIRMED by the EBOOT reverse (dev/chatgpt-eboot-{4,5}.txt): values[0].xyz (0x20/0x24/0x28) is + // an RGB PARAMETER TRIPLE, not an alpha threshold and not detail strengths. The engine loads the + // three together, multiplies them by the instance's own RGB when the Spatial Lighting flag + // (renderFlags 0x10) is set, and uploads them as a vertex constant. Two consequences: + // - "alphaClip = 0x20" is REFUTED. The real alpha thresholds are hardcoded per rendering mode by + // the renderer (Cutout GEQUAL 128, the blended paths GEQUAL 4), not stored per material. + // - The old "detail strengths" at 0x28/0x2C/0x30 were misplaced onto this triple and the next + // vector; they are NOT detail strengths and have been removed rather than left misleading. + // Names stay neutral (value0X..) until each is traced to its GPU constant. 0x34 and 0x3C are known + // to be live (the engine reads/copies/adjusts them at load) but their meaning is still unknown. // Do NOT map these onto the shader's fc[N] by index: the engine assembles that constant block - // from several sources, and the obvious values[k] -> fc[k+3] fit breaks immediately (it would - // put fc[3]..fc[5], which carry the detail strengths, past the end of this 0x80 structure). + // from several sources, and the obvious values[k] -> fc[k+3] fit breaks immediately. // --------------------------------------------------------------------------------------- - [FileOffset(0x20)] public float alphaClip; - [FileOffset(0x24), Reference(0x04)] public byte[] Unk2a; - // Detail-map per-channel strengths, matching the captured shader's detailNormalStrength / - // detailSpecStrength / detailAlbedoStrength fragment constants (fc[4].xy, fc[5].w, fc[5].z). - // OFFSETS ARE A HYPOTHESIS, not confirmed: the triple may instead start one float earlier at - // 0x24/0x28/0x2C, which would shift all three. Unk2a directly above is that candidate slot — - // it is deliberately left as its own 4-byte range so the ShaderBrowser still prints it as a - // float next to these, making the two readings directly comparable. - [FileOffset(0x28)] public float detailNormalStrength; - [FileOffset(0x2C)] public float detailSpecStrength; - [FileOffset(0x30)] public float detailAlbedoStrength; - [FileOffset(0x34), Reference(0x1C)] public byte[] Unk2b; + [FileOffset(0x20)] public float value0X; + [FileOffset(0x24)] public float value0Y; + [FileOffset(0x28)] public float value0Z; + [FileOffset(0x2C)] public float value0W; + [FileOffset(0x30)] public float value1X; + [FileOffset(0x34)] public float value1Y; + [FileOffset(0x38), Reference(0x18)] public byte[] Unk2b; [FileOffset(0x50)] public float parallaxScale; [FileOffset(0x54)] public float parallaxBias; // Detail-map UV tiling. This is the multiplier the captured fragment shader can NOT show: // there the detail UV arrives already tiled in a vertex interpolant (tc6.xy), so the frequency - // is applied upstream — which is exactly why it has to live in the material metadata. + // is applied upstream - which is exactly why it has to live in the material metadata. [FileOffset(0x58)] public float detailTiling; [FileOffset(0x5C), Reference(0x24)] public byte[] Unk3; @@ -93,7 +106,7 @@ public record struct ShaderMetadataNew : ILunaSerializable [FileOffset(0x21)] public byte renderingMode; [FileOffset(0x22), Reference(0x0E)] public byte[] Unk2; [FileOffset(0x30)] public float alphaClip; - // One contiguous unknown run, 0x34 to the end of the structure — see ShaderMetadataOld's Unk2 + // One contiguous unknown run, 0x34 to the end of the structure - see ShaderMetadataOld's Unk2 // for why the old three-way split (Unk3a 0x34 / Unk4 0x48 / Unk3b 0x50) was an artifact of a // retracted hypothesis rather than a real field boundary. [FileOffset(0x34), Reference(0x4C)] public byte[] Unk3; diff --git a/ReLunacy.Engine/Loading/TextureShaderLoader.cs b/ReLunacy.Engine/Loading/TextureShaderLoader.cs index 88b1ac4..be61608 100644 --- a/ReLunacy.Engine/Loading/TextureShaderLoader.cs +++ b/ReLunacy.Engine/Loading/TextureShaderLoader.cs @@ -6,13 +6,21 @@ namespace ReLunacy.Engine.Loading; // Loads every texture and shader up front, keyed by TUID (new engine) or flat index (old -// engine) — this is what mesh shaderIndex fields resolve through. Shaders must load after +// engine) - this is what mesh shaderIndex fields resolve through. Shaders must load after // textures: shader construction resolves albedo/normal/expensive texture references immediately. public sealed class TextureShaderLoader { public readonly Dictionary Textures = []; public readonly Dictionary Shaders = []; + /// Old-engine textures in PHYSICAL ORDER of the 0x5200 section — element N is the + /// descriptor at sectionOffset + N * 0x20. This is the addressing a direct texture-index field + /// uses (e.g. ): the game computes + /// section5200Base + index * 0x20 and reads the descriptor there, so POSITION is the identity, + /// not the offset-derived key is keyed by. Same Texture instances as + /// , just also held in order. Empty on the new engine. + public readonly List OldTexturesByIndex = []; + private readonly FileManager _fileManager; public TextureShaderLoader(FileManager fileManager) @@ -55,7 +63,7 @@ private void LoadTexturesNew() var highmipsPtrSec = assetlookup.QuerySection(Texture.HighmipsPointerID); var textureMetaSec = assetlookup.QuerySection(TextureMetadataNew.ID); // Lower-resolution single-mip fallback copies, embedded directly in textures.dat, - // index-aligned with the metadata/highmip-pointer tables above — see + // index-aligned with the metadata/highmip-pointer tables above - see // Texture.ReadTexture's lowres fallback branch. Absent on some levels (QuerySection // returns a zero-length default header when the section doesn't exist at all). var textureRefSec = assetlookup.QuerySection(0x1D180); @@ -65,14 +73,14 @@ private void LoadTexturesNew() // assetlookup.dat's section headers carry an unreliable `count` field for these // pointer/metadata-table sections (same quirk already worked around for zone/moby/tie - // pointer tables elsewhere) — `length / record size` is the real entry count. Using + // pointer tables elsewhere) - `length / record size` is the real entry count. Using // `.count` directly here was loading only 1 of 1458 textures for this level. uint textureCount = textureMetaSec.length / TextureMetadataNew.Size; for (uint i = 0; i < textureCount; i++) { alstream.Seek(textureMetaSec.offset + TextureMetadataNew.Size * i); // Texture's new-engine constructor branch never sets `id` itself (that's normally - // ReadHighmipsPtr's job, which this bypasses since highmipsPtrs is already read) — + // ReadHighmipsPtr's job, which this bypasses since highmipsPtrs is already read) - // every texture was silently getting id=0, which only surfaced once the count fix // above made this loop run more than once (id=0 duplicate on the 2nd texture). var tex = new Texture(alstream) { highmipsRef = highmipsPtrs[i], id = highmipsPtrs[i].TUID }; @@ -113,6 +121,9 @@ private void LoadTexturesOld() mainStream.Seek(textureMetadataSection.offset + TextureMetadataOld.Size * i); var texture = new Texture(mainStream, true); Textures.Add(texture.id, texture); + // Physical-position index, in lockstep with `i` — this is what direct index fields + // resolve through (see OldTexturesByIndex / ResolveOldTextureIndex). + OldTexturesByIndex.Add(texture); if (texstream is not null) texture.highmipsMetadatasOld = []; } @@ -127,10 +138,10 @@ private void LoadTexturesOld() } // texstreamReferences.index is the TARGET texture's index, not a 1:1 position in this - // list — a texstream override only exists for a subset of textures. The previous loop + // list - a texstream override only exists for a subset of textures. The previous loop // used its own counter `i` as both the reference-list position AND the texture-array // index, which are different things: any reference whose own .index was >= - // texstreamRefSection.count (entirely plausible — the ref list only has as many + // texstreamRefSection.count (entirely plausible - the ref list only has as many // entries as overridden textures, which can be indexed anywhere in the full texture // table) was silently skipped, and the reference actually found at position i was // applied to the wrong texture whenever the two diverged. @@ -143,11 +154,11 @@ private void LoadTexturesOld() } // Per texture, not a single stream for the whole level: only textures with their own - // texstream override (highmipsMetadatasOld non-empty) read from texstream.dat — its + // texstream override (highmipsMetadatasOld non-empty) read from texstream.dat - its // offsets are meaningless against textures.dat and vice versa. The previous single // `streamToRead = texstream ?? textures` read EVERY texture from texstream.dat whenever // that file existed at all, even textures with no override entry, seeking to garbage - // offsets for all of them — texstream.dat only overrides a subset of textures on levels + // offsets for all of them - texstream.dat only overrides a subset of textures on levels // that have one at all (e.g. Tools of Destruction's meridian_city). foreach (var tex in Textures.Values) { @@ -160,23 +171,39 @@ private void LoadTexturesOld() LoadEnvironmentCubemapAverage(main); } + /// Resolves a DIRECT old-engine texture index — a physical position in the 0x5200 + /// table (see ) — to its texture. Returns null for the + /// 0xFFFFFFFF "no texture" sentinel and for any index past the end of the table, so callers get + /// the game's own fallback behaviour rather than an exception or a wrapped 4-billion index. + /// This is exactly the addressing the game applies to + /// . + public Texture? ResolveOldTextureIndex(uint index) + { + // 0xFFFFFFFF is the game's -1 "no resource" sentinel (see the EBOOT test at 0x4E2304, and + // FoliageMetadata.NoTexture for the foliage field that uses it). Kept inline rather than + // referencing that foliage constant so this stays a general old-texture-index resolver. + if (index == 0xFFFFFFFF || index >= (uint)OldTexturesByIndex.Count) + return null; + return OldTexturesByIndex[(int)index]; + } + public const uint CubemapSectionId = 0x5920; /// Average colour of the level's environment cubemap, or null when there isn't one. /// An APPROXIMATION on purpose: the game reflects a real cubemap, but its contents in metropolis /// are a near-uniform grey, so a single colour captures almost all of what it contributes /// without needing a samplerCube binding or the exact face/mip layout (which is not pinned down - /// — with 6 mips a face is 5460 bytes, not 4096, so the ordering still has to be established). + /// - with 6 mips a face is 5460 bytes, not 4096, so the ordering still has to be established). /// public System.Numerics.Vector3? EnvironmentAverage { get; private set; } /// Reads the cubemap reference at section 0x5920 and averages it. /// Two things about this are unlike every other texture here. Its pixel data lives in MAIN.DAT - /// itself, not textures.dat — reading the offset against textures.dat lands in an index buffer. + /// itself, not textures.dat - reading the offset against textures.dat lands in an index buffer. /// And its RGB is a near-white greyscale MANTISSA with the real variation carried in alpha as a /// shared HDR exponent (see the captured shader: envColour = rgb * exp2(a * scale + bias)). /// The exponent's scale/bias are fragment constants we cannot source, so alpha is folded in as a - /// plain 0..1 weight rather than decoded — enough for an average, not a substitute for the real + /// plain 0..1 weight rather than decoded - enough for an average, not a substitute for the real /// decode. private void LoadEnvironmentCubemapAverage(IGFile main) { @@ -192,7 +219,7 @@ private void LoadEnvironmentCubemapAverage(IGFile main) double r = 0, g = 0, b = 0, weight = 0; for (int i = 0; i + 3 < pixels.Length; i += 4) { - // Stored A,R,G,B — confirmed by alpha being the only channel that varies. + // Stored A,R,G,B - confirmed by alpha being the only channel that varies. double a = pixels[i] / 255.0; r += pixels[i + 1] / 255.0 * a; g += pixels[i + 2] / 255.0 * a; @@ -209,23 +236,23 @@ private void LoadEnvironmentCubemapAverage(IGFile main) public const uint ZoneDirectionalSectionId = 0x5410; /// Baked light COLOUR per lightmapped instance (main.dat section 0x5400). Indexed - /// positionally by TieInstance.LightmapIndex — entry X of this list and of ZoneDirectionals + /// positionally by TieInstance.LightmapIndex - entry X of this list and of ZoneDirectionals /// belong to the same instance. Empty on the new engine (see LoadZoneLightingSection). public readonly List ZoneLightmaps = []; /// Baked light DIRECTION, tangent space (main.dat section 0x5410), same indexing as - /// ZoneLightmaps. InsomniaToolset names this section "ShadowMap"; that is wrong — the game + /// ZoneLightmaps. InsomniaToolset names this section "ShadowMap"; that is wrong - the game /// shader dots it with a tangent-space normal and divides by its .z, a directional-lightmap /// operation. public readonly List ZoneDirectionals = []; /// Reads a zone lighting section. These use the identical 0x20-byte layout as regular /// textures (0x5200), with pixel data in textures.dat, so they go through exactly the same - /// Texture/ReadTexture path — that shared layout is why this is cheap. + /// Texture/ReadTexture path - that shared layout is why this is cheap. /// Old engine only: on the new engine the pixel data moves to lighting.dat behind an /// assetlookup resource, which isn't wired up here. /// Entries are added even when a read fails, so this list stays POSITIONALLY aligned with the - /// indices that reference it — dropping a bad entry would silently shift every later index. + /// indices that reference it - dropping a bad entry would silently shift every later index. /// private static void LoadZoneLightingSection(IGFile main, StreamHelper textures, uint sectionId, List into) { @@ -259,7 +286,7 @@ private void LoadShadersNew() var shaderStream = new StreamHelper(shadersStream, StreamHelper.Endianness.Big); var shaderPtrSec = assetlookup.QuerySection(Shader.PointerID); - // Same count-field-is-unreliable quirk as the texture metadata section above — this was + // Same count-field-is-unreliable quirk as the texture metadata section above - this was // loading only 1 of 693 shaders for this level, leaving nearly every mesh's material // resolution falling back to the default material. uint shaderCount = shaderPtrSec.length / AssetPointer.Size; @@ -275,7 +302,7 @@ private void LoadShadersNew() throw new InvalidOperationException("Textures must be loaded before shaders."); // ShaderReference's albedoID/normalID/expensiveID (and Legacy's identical NewReferences - // struct) are only 32-bit — the low half of a texture's full 64-bit TUID — so they can't + // struct) are only 32-bit - the low half of a texture's full 64-bit TUID - so they can't // match Textures' full-TUID keys directly. Legacy's own texture dictionary is likewise // keyed by the truncated 32-bit value; mirror that here for the lookup. var texturesByLow32 = new Dictionary(); @@ -316,7 +343,7 @@ private void LoadShadersNew() shader.name = shadstream.ReadString(sref.namePointer); // ShaderReference's own embedded TUID field (offset 0x00) reads as a genuine 0 for - // every shader in this format — Legacy never relies on it either, keying its shader + // every shader in this format - Legacy never relies on it either, keying its shader // dictionary by the assetlookup pointer-table TUID (shaderPtrs[i].tuid) instead, same // as this codebase's Moby/Zone/Tie readers already do for their own asset tables. Shaders.Add(ptr.TUID, shader); diff --git a/ReLunacy.Engine/Loading/Textures/Texture.cs b/ReLunacy.Engine/Loading/Textures/Texture.cs index 38fe314..62ad5c2 100644 --- a/ReLunacy.Engine/Loading/Textures/Texture.cs +++ b/ReLunacy.Engine/Loading/Textures/Texture.cs @@ -26,7 +26,7 @@ public class Texture private static readonly HashSet _loggedSuspiciousTextures = []; // Block-compressed formats are always read/stored linear regardless of the per-instance - // linear bit/prefix (see TextureMetadataOld/New.IsLinear) — DXT/BC compression has no + // linear bit/prefix (see TextureMetadataOld/New.IsLinear) - DXT/BC compression has no // swizzled-on-disk variant in this format family. private static bool IsBlockCompressed(TextureFormat format) => format is TextureFormat.DXT1 or TextureFormat.DXT3 or TextureFormat.DXT5 or TextureFormat.BC4 or TextureFormat.BC5; @@ -66,7 +66,7 @@ public Texture(StreamHelper sh, bool old = false) textureMetadata = TextureMetadataNew.Read(sh); // Same as the old-engine branch above: TextureMetadataNew already derives Width/Height // from widthPow/heightPow, but nothing copied them onto the Texture itself, so every - // new-engine texture stayed at the default 0/0 — BlockDecoder.Decode then throws on + // new-engine texture stayed at the default 0/0 - BlockDecoder.Decode then throws on // the first DXT texture it tries to build (srcWidth/srcHeight must be non-zero). Width = textureMetadata.Width; Height = textureMetadata.Height; @@ -80,7 +80,7 @@ public void ReadHighmipsPtr(StreamHelper sh) } /// In new engine, must be the highmips stream. / - /// (new engine only) are the assetlookup 0x1D180 fallback — a single-mip copy + /// (new engine only) are the assetlookup 0x1D180 fallback - a single-mip copy /// embedded directly in textures.dat, used when this texture has no highmip data at all. public void ReadTexture(StreamHelper sh, StreamHelper? lowresStream = null, AssetPointer? lowresRef = null) { @@ -113,20 +113,20 @@ public void ReadTexture(StreamHelper sh, StreamHelper? lowresStream = null, Asse data = new byte[hmref.length]; // Diagnostic: the highmip entry's own length should match what the block decoder - // will actually expect for this texture's Width/Height/format — if it doesn't, + // will actually expect for this texture's Width/Height/format - if it doesn't, // the decoder gets handed a buffer that's the wrong size for the dimensions it's // told to decode, which for a short buffer reads as flat/degenerate output (the // reported new-engine DXT1/DXT5 "unicolor" symptom) without throwing anything. if (IsBlockCompressed(TexFormat) && _loggedSuspiciousTextures.Add(id) && hmref.length != HighmipSize) - Console.WriteLine($"Diagnostic: texture {id:X} ('{name}') is {TexFormat} at {Width}x{Height} — highmip entry is {hmref.length} bytes but decoding at these dimensions expects {HighmipSize} bytes."); + Console.WriteLine($"Diagnostic: texture {id:X} ('{name}') is {TexFormat} at {Width}x{Height} - highmip entry is {hmref.length} bytes but decoding at these dimensions expects {HighmipSize} bytes."); } else if (lowresStream is not null && lowresRef is { length: > 0 } lref && HighmipSize > 0) { - // No highmip data for this texture — fall back to the lower-resolution single-mip + // No highmip data for this texture - fall back to the lower-resolution single-mip // copy embedded directly in textures.dat (assetlookup section 0x1D180), same as // ReLunacy-Ymir's `useLowres` path. Previously this case just returned with `data` // left at its default `[]`, which decodes to null and renders as - // GlobalResource.DefaultModelTexture — a flat placeholder that looks exactly like + // GlobalResource.DefaultModelTexture - a flat placeholder that looks exactly like // the reported "unicolor" bug, for any texture whose highmip entry is legitimately // empty (a normal, common case on new engine, not corruption). source = lowresStream; @@ -143,7 +143,7 @@ public void ReadTexture(StreamHelper sh, StreamHelper? lowresStream = null, Asse throw new IndexOutOfRangeException($"Offset is out of bounds: {offset:X}/{source.BaseStream.Length:X}"); // Whether to unswizzle is a per-instance property (see ITextureMetadata.IsLinear), not - // something derivable from the format alone — the previous `TexFormat > A8R8G8B8` check + // something derivable from the format alone - the previous `TexFormat > A8R8G8B8` check // only worked by coincidence for the 5 formats that existed before this format list was // expanded (every "> A8R8G8B8" format happened to also be DXT). It breaks for RGBA4/G8B8, // which the new-engine prefix scheme can mark either swizzled OR linear per texture. @@ -182,15 +182,30 @@ public void Unswizzle(StreamHelper sh) } } - private static int MortonSwizzle(int index, int width, int height) + /// Un-swizzles a linear buffer of Morton/GCM-swizzled pixels into row-major order. + /// Shared with CubemapReader, whose faces use the same swizzle even though their metadata's + /// linear bit reads set - see that reader. dst[MortonSwizzle(i)] = src[i], mirroring the + /// instance above but operating on an in-memory buffer. + internal static byte[] Deswizzle(ReadOnlySpan src, int width, int height, int pixelSize) { - // The row-stride multiplier below must be the ORIGINAL width, not the loop-shifted copy — + var dst = new byte[width * height * pixelSize]; + for (int i = 0; i < width * height; i++) + { + int index = MortonSwizzle(i, width, height); + src.Slice(i * pixelSize, pixelSize).CopyTo(dst.AsSpan(pixelSize * index)); + } + return dst; + } + + internal static int MortonSwizzle(int index, int width, int height) + { + // The row-stride multiplier below must be the ORIGINAL width, not the loop-shifted copy - // `width` gets shifted down to 1 by the end of the loop below (that's how it tracks when // to stop consuming bits for the X axis), so using the parameter directly in the final // `yMortonValue * width + xMortonValue` silently used a stride of 1 instead of the real // row width. That collapses most (x,y) pairs onto the same handful of destination indices - // instead of spreading them across the full width*height buffer — every swizzled texture - // (every non-DXT, non-linear one — DXT/linear textures are read raw and never call this) + // instead of spreading them across the full width*height buffer - every swizzled texture + // (every non-DXT, non-linear one - DXT/linear textures are read raw and never call this) // came out scrambled, while unswizzled reads looked fine, matching the reported symptom of // some textures being broken and others not. ReLunacy-Ymir's equivalent Morton() takes the // same approach but keeps the original `x` parameter untouched for exactly this reason. diff --git a/ReLunacy.Engine/Loading/Textures/TextureFormat.cs b/ReLunacy.Engine/Loading/Textures/TextureFormat.cs index a232c5b..3e62d51 100644 --- a/ReLunacy.Engine/Loading/Textures/TextureFormat.cs +++ b/ReLunacy.Engine/Loading/Textures/TextureFormat.cs @@ -1,7 +1,7 @@ namespace ReLunacy.Engine.Loading.Textures; // Values match the raw 4-bit old-engine format code (TextureMetadataOld's (formatBitfield >> 8) & -// 0xF) directly, ported from ReLunacy-Ymir's CTexture.TexFormat — that fork's texture handling is +// 0xF) directly, ported from ReLunacy-Ymir's CTexture.TexFormat - that fork's texture handling is // confirmed working across formats this enum previously didn't even have members for (R8, // A1R5G5B5, BC4, BC5, G8B8), which is why old-engine levels using those formats (e.g. Tools of // Destruction's meridian_city) failed to decode. RGBA4/RGBA16F can't come from the old-engine 4-bit diff --git a/ReLunacy.Engine/Loading/Textures/TextureMetadataNew.cs b/ReLunacy.Engine/Loading/Textures/TextureMetadataNew.cs index e9ccda1..f0a30c2 100644 --- a/ReLunacy.Engine/Loading/Textures/TextureMetadataNew.cs +++ b/ReLunacy.Engine/Loading/Textures/TextureMetadataNew.cs @@ -17,11 +17,11 @@ public record struct TextureMetadataNew : ILunaSerializable, ITextureMetadata public readonly uint Width => (uint)1 << widthPow; public readonly uint Height => (uint)1 << heightPow; - // New engine's raw format byte is NOT the same numbering as old engine's 4-bit code — it's + // New engine's raw format byte is NOT the same numbering as old engine's 4-bit code - it's // prefixed 0x8X (Morton-swizzled) or 0xAX (linear), plus a couple of bare/special values // (0x01-0x0B, 0x9A). The previous `(TextureFormat)format` cast skipped this normalization // entirely, so every new-engine texture whose format byte didn't happen to equal one of - // TextureFormat's raw old-engine values (3/5/6/7/8) decoded to a garbage enum value instead — + // TextureFormat's raw old-engine values (3/5/6/7/8) decoded to a garbage enum value instead - // ported from ReLunacy-Ymir's CTexture.NormalizeNewEngineFormat, which is confirmed working. public readonly TextureFormat Format => NormalizeFormat(format); @@ -54,7 +54,7 @@ private static TextureFormat NormalizeFormat(byte raw) 0x88 or 0xA8 or 0x08 => TextureFormat.DXT5, 0x8B or 0xAB or 0x0B => TextureFormat.G8B8, 0x9A => TextureFormat.RGBA16F, - _ => (TextureFormat)0xFF, // unrecognized — Texture.ReadTexture must treat this as unreadable + _ => (TextureFormat)0xFF, // unrecognized - Texture.ReadTexture must treat this as unreadable }; private static bool FormatIsLinear(byte raw) diff --git a/ReLunacy.Engine/Loading/Textures/TextureMetadataOld.cs b/ReLunacy.Engine/Loading/Textures/TextureMetadataOld.cs index 58195d2..6e30e37 100644 --- a/ReLunacy.Engine/Loading/Textures/TextureMetadataOld.cs +++ b/ReLunacy.Engine/Loading/Textures/TextureMetadataOld.cs @@ -29,22 +29,15 @@ public record struct TextureMetadataOld : ILunaSerializable, ITextureMetadata Format is TextureFormat.DXT1 or TextureFormat.DXT3 or TextureFormat.DXT5 or TextureFormat.BC4 or TextureFormat.BC5 || ((formatBitfield >> 2) & 1) != 0; - // Unk1 (0x08-0x18) has never been decoded — it's raw, unexamined bytes. This struct's ID + // Unk1 (0x08-0x18) has never been decoded - it's raw, unexamined bytes. This struct's ID // (0x5200) and total size (0x20) match InsomniaToolset's PS3 "NV4097_SET_*TEXTURE_* // registry dump" Texture struct field-for-field where it's been verified (offset/numMips at // the same spots, width/height at the same 0x18/0x1A), which is a strong (but NOT yet - // confirmed against our own real data) signal this is the same underlying struct. In that - // struct, byte range 0x08-0x18 covers address/control0/control3/filter, and control0 (the - // third 4-byte word, i.e. Unk1[4..8], file offset 0x0C-0x10) carries a 1-bit "alphaKill" - // flag at bit 29 of that little-endian uint32 — i.e. bit 5 (mask 0x20) of Unk1[7] (file - // offset 0x0F). Exposed here purely as a diagnostic (see MaterialReader.WrapTexture) so real - // level data can confirm or refute it correlates with textures that should be transparent — - // nothing reads this for actual rendering decisions yet. - public readonly bool AlphaKillCandidate => Unk1 != null && Unk1.Length > 7 && (Unk1[7] & 0x20) != 0; + // confirmed against our own real data) signal this is the same underlying struct. // One-shot diagnostic: which old-engine formatBitfield values actually appear in real level // data, and whether the per-instance linear bit (bit 2) is ever set. Neither the format-code - // range nor that bit has been confirmed against real data before now — this settles both from + // range nor that bit has been confirmed against real data before now - this settles both from // the next level load instead of assuming the ReLunacy-Ymir port's decode is exhaustive. private static readonly HashSet _loggedFormatBitfields = []; diff --git a/ReLunacy.Engine/Loading/Vertices/FoliageSprite.cs b/ReLunacy.Engine/Loading/Vertices/FoliageSprite.cs new file mode 100644 index 0000000..12f51ee --- /dev/null +++ b/ReLunacy.Engine/Loading/Vertices/FoliageSprite.cs @@ -0,0 +1,110 @@ +using ReLunacy.Engine.Loading.IO; + +namespace ReLunacy.Engine.Loading.Vertices; + +/// One CORNER of a foliage sprite card: four big-endian half floats, 8 bytes, one per +/// vertex. This is RSX attribute location 0 of the foliage vertex program. +/// +/// CONFIRMED AGAINST A CAPTURE, not inferred. RPCS3's DrawParametersBuffer for metropolis +/// (dev/VS_buffer_set0-2.csv) contains 3,133 draws with this exact layout, all three attributes +/// agreeing on stride and frequency in every single one: +/// loc0 stride=8 SFLOAT16 x4 frequency=1 -> THIS, one per corner +/// loc1 stride=8 SFLOAT16 x3 frequency=4 -> FoliageSpriteAnchor, one per QUAD +/// loc2 stride=8 UBYTE_RAW x4 frequency=4 -> the same 8-byte record, read at +4 +/// The frequency=4 divisor is what makes the layout make sense: loc1/loc2 advance once every four +/// corners, so a 468-corner card set has only 117 anchor records behind it. +/// +/// The four halves are (offsetX, offsetY, u, v), and the game's own vertex program says so: +/// r4.xy = in_pos.xy; // corner offset +/// r3.xy = in_pos.zw; ... dst_reg9 = r3; tc2 = dst_reg9; +/// and the foliage fragment program samples with exactly that: texture(tex0, tc2.xy). +/// The offset is added to the transformed anchor, scaled by a vertex constant: +/// r2.xyz = fma(r4.xyz, vc[41].x, transform(in_weight.xyz)) +/// so / are in the card's own 2D plane and become world +/// units only after that constant is applied. +/// +/// THE UVs ADDRESS ONE QUADRANT OF THE ATLAS. Over all 468 corners of metropolis's foliage the UV +/// components take exactly five values and nothing else: 0.0 (300x), -0.5 (234x), 0.5 (234x), +/// -1.0 (166x), 1.0 (2x). Every one is a multiple of 0.5, so the texture is split into four +/// quadrants and each sprite picks one. V is NEGATIVE, i.e. the vertical axis is flipped relative +/// to this renderer's convention - do not "fix" that by clamping, negate it (see +/// FoliageReader.ReadCorners) or the card samples the wrong quadrant. +public readonly record struct FoliageSpriteCorner +{ + /// Bytes per corner - the RSX attribute's stride. + public const uint Size = 0x08; + + /// Corners per sprite card. Also the frequency divisor on the anchor attribute. + public const int CornersPerSprite = 4; + + public readonly float OffsetX; + public readonly float OffsetY; + public readonly float U; + public readonly float V; + + public FoliageSpriteCorner(float offsetX, float offsetY, float u, float v) + { + OffsetX = offsetX; + OffsetY = offsetY; + U = u; + V = v; + } + + public static FoliageSpriteCorner Read(StreamHelper sh) => + new((float)sh.ReadHalf(), (float)sh.ReadHalf(), (float)sh.ReadHalf(), (float)sh.ReadHalf()); +} + +/// The per-QUAD record behind a foliage card: three big-endian half floats of anchor +/// position plus two trailing bytes, 8 bytes total, one per sprite rather than per vertex. +/// +/// This is attributes location 1 and location 2 of the same 8-byte record - the capture shows loc2 +/// starting exactly 4 bytes after loc1, both at stride 8 with frequency 4. loc1 is SFLOAT16 x3, so +/// it occupies bytes +0..+5; the vertex program then reads only in_normal.zw out of loc2's +/// four raw bytes, which is bytes +6 and +7. Nothing reads +4/+5 twice - the halves and the two +/// used bytes tile the record exactly. +/// +/// The anchor is transformed by vertex constants 32..35 (an object-to-world matrix) before the +/// corner offset is added, so it is the sprite's position in the foliage asset's local space. +/// +/// The two trailing bytes drive an ADDRESS REGISTER in the vertex program: +/// r3.zw = (in_normal.zw >= vc[467].x); // a threshold test per byte +/// r4.zw = fma(-r3.zw, vc[467].x, in_normal.zw); // subtract it back out - integer unpacking +/// a0.x = int(r0.w * vc[467].y); +/// ... vc[42 + a0.x], vc[43 + a0.x] // indexed constant lookup +/// which selects a per-sprite 2x2 rotation applied to the corner offsets, gated on the high byte's +/// threshold flag. So these two bytes are a packed (rotation index, flags) pair. The exact packing +/// is NOT decoded here, because the constants it indexes live in the game's vertex constant block +/// and are not in the level files - reproducing the rotation needs those, or needs the rotation to +/// be re-derived. Stored raw so nothing is silently invented. +public readonly record struct FoliageSpriteAnchor +{ + /// Bytes per sprite - the RSX attribute's stride, with a frequency divisor of 4. + public const uint Size = 0x08; + + public readonly float X; + public readonly float Y; + public readonly float Z; + + /// Raw bytes at +6 and +7 - see the type comment. Packed, not yet decoded. + public readonly byte Packed0; + public readonly byte Packed1; + + public FoliageSpriteAnchor(float x, float y, float z, byte packed0, byte packed1) + { + X = x; + Y = y; + Z = z; + Packed0 = packed0; + Packed1 = packed1; + } + + public static FoliageSpriteAnchor Read(StreamHelper sh) + { + float x = (float)sh.ReadHalf(); + float y = (float)sh.ReadHalf(); + float z = (float)sh.ReadHalf(); + byte p0 = sh.ReadByte(); + byte p1 = sh.ReadByte(); + return new FoliageSpriteAnchor(x, y, z, p0, p1); + } +} diff --git a/ReLunacy.Engine/Loading/Vertices/PackedNormal.cs b/ReLunacy.Engine/Loading/Vertices/PackedNormal.cs index 80dc720..34d04ff 100644 --- a/ReLunacy.Engine/Loading/Vertices/PackedNormal.cs +++ b/ReLunacy.Engine/Loading/Vertices/PackedNormal.cs @@ -4,14 +4,14 @@ namespace ReLunacy.Engine.Loading.Vertices; // Confirmed empirically against real level data, not reverse-engineered from a spec: the raw // normal/tangent uint32 words on VertexFormat0/VertexFormat1 are a signed 11:11:10 packed vector -// (X: bits 0-10, Y: bits 11-21, Z: bits 22-31 — the file's own big-endian byte order, no +// (X: bits 0-10, Y: bits 11-21, Z: bits 22-31 - the file's own big-endian byte order, no // byte-swap), each lane normalized by its own signed max magnitude (1023 for the 11-bit lanes, // 511 for the 10-bit lane). Verified by decoding ~10 real vertices from unrelated meshes/materials // and checking |xyz|: this exact layout landed within rounding error of 1.0 on every one of them -// (byte-reversed and alternative bit orderings scattered from 0.1 to 1.7 on the same words) — +// (byte-reversed and alternative bit orderings scattered from 0.1 to 1.7 on the same words) - // including a mirrored-vertex pair (Tie material 0x3C) that decoded to an exact Z-axis reflection // of itself, which a wrong packing could not produce by chance. Both normal and tangent fully -// consume all 32 bits under this layout — there is no leftover component (e.g. a W lane) hiding +// consume all 32 bits under this layout - there is no leftover component (e.g. a W lane) hiding // a per-vertex opacity value, which was the working hypothesis this decode was built to test. internal static class PackedNormal { diff --git a/ReLunacy.Engine/Loading/Vertices/TieLightmapUV.cs b/ReLunacy.Engine/Loading/Vertices/TieLightmapUV.cs new file mode 100644 index 0000000..9fbeef8 --- /dev/null +++ b/ReLunacy.Engine/Loading/Vertices/TieLightmapUV.cs @@ -0,0 +1,198 @@ +using ReLunacy.Engine.Loading.IO; + +namespace ReLunacy.Engine.Loading.Vertices; + +/// A tie's LIGHTMAP UV channel: one pair of big-endian half floats per vertex, in its own +/// tightly packed 4-byte-stride array OUTSIDE the 20-byte record. +/// +/// CONFIRMED BY THE GAME'S OWN VERTEX PROGRAM (dev/ties/ties_vertex_shader_LOD0.glsl). That program +/// reads five attributes and routes them like this: +/// location 0 -> in_pos SINT16 x4 @+0 position.xyz, and see VertexFormat0.boneIndex +/// location 1 -> in_weight SFLOAT16x2 @+8 -> tc0.XY (albedo/base UV) +/// location 2 -> in_normal CMP @+12 -> tc3 (normal) +/// location 3 -> in_diff_color CMP @+16 -> tc4 (TANGENT, not a colour) +/// location 4 -> in_spec_color THIS stride 4, own stream +/// and then, verbatim: +/// r0.z = in_spec_color.xy.x; r0.w = in_spec_color.xy.y; dst_reg7 = r0; tc0 = dst_reg7; +/// so tc0.ZW IS LOCATION 4 - the exact place the tie fragment programs sample the baked light +/// colour (tex4) and light direction (tex14). No scale, no bias, no flip is applied on the way: +/// whatever bytes are in the file are the texture coordinates. A V flip was tried in the loader and +/// looked worse in the app, so ties use these UVs raw - if the orientation is ever wrong again, fix +/// it in the sampling path UFrags share, not per asset type. +/// The names in that listing are RSX's fixed attribute-slot names (0=position, 1=weight, 2=normal, +/// 3=diffuse colour, 4=specular colour, ...), not semantics - Insomniac repurposed slots 3 and 4. +/// +/// This is not a second UV set bolted into VertexFormat0 - there is no room in it, and searching it +/// is what kept failing. RPCS3's captured DrawParametersBuffer for a tie draw +/// (dev/ties/VS_ties_set0-2-buffer.bin) describes every attribute of every draw in the frame, and +/// the tie shape is unambiguous. Of 83,014 draws whose attribute 0 is a stride-20 SINT16 x4 +/// position (VertexFormat0's exact size and type), 81,197 bind a FIFTH attribute at location 4 +/// living in its own stream at stride 4: +/// attr0 stride=20 offset=+0 SINT16 x4 -> VertexFormat0.position + boneIndex +/// attr1 stride=20 offset=+8 SFLOAT16 x2 -> VertexFormat0.UVs +/// attr2 stride=20 offset=+12 CMP 11:11:10 -> VertexFormat0 packed normal +/// attr3 stride=20 offset=+16 CMP 11:11:10 -> VertexFormat0 packed tangent +/// attr4 stride=4 (own stream) -> THIS +/// attr4 is SFLOAT16 x2 on 30,015 of those draws and UBYTE x4 on the other 51,182 - mutually +/// exclusive, same slot. THE SLOT IS POLYMORPHIC, and the reduced tie fragment programs show what +/// the other reading is. In ties_fragment_shader_far.glsl: +/// h1.w = clamp16(tc0.zzzz).w; +/// h1.xyz = clamp16(h3 * h1.wwww).xyz; // light term SCALED by tc0.z +/// and in ties_fragment_shader_medium_no_normal.glsl: +/// h0.w = clamp16(tc0.zzzz).w; +/// h0.xyz = mix(h0.xyz, tex0.xyz, tc0.z != 0); +/// h0.xyz = clamp16(h0 * h0.wwww).xyz; // albedo SCALED by tc0.z +/// tc0.z is location 4's FIRST component, so in those variants this attribute is not a texture +/// coordinate at all - it is a per-vertex scalar multiplying the surface colour. That is the +/// PER-VERTEX BAKED LIGHTING the WWS post-mortem budgets for ("50 MB Baked lighting data (mix of +/// light maps and per-vertex data)"). So: half x2 here = lightmap UV pair; UNORM8 x4 here = baked +/// per-vertex light, of which these variants consume one channel. +/// Which one a draw gets is a per-USE decision, the same mechanism as every other knob in "Shader +/// Usage Controls" - the full-featured program samples the baked atlases, the shader-LOD/reduced +/// programs take the cheap per-vertex term instead. +/// This corrects an earlier claim in this file that UNORM8 here meant "an 8-bit UV pair, not vertex +/// colour". That is true only of the LOD0 program, which consumes .xy as a pair; it is not true of +/// the family. Note also 0x18 is not where the UNORM8 arrays live: decoded there as a u8 UV pair the +/// area test below gives median r = -0.099 across 163 ties against +0.899 for the half decode - but +/// that test assumes UVs, so it says nothing about the vertex-colour reading either way. +/// Every one of the 81,197 has frequency=1 and modulo=0, i.e. genuinely indexed per vertex, not a +/// per-instance divisor; and swap_bytes=1, i.e. big-endian source, same as every other attribute +/// here. Hence: 4 bytes per vertex, two big-endian halves, exactly one entry per vertex. +/// +/// WHERE THE ARRAY LIVES - SOLVED, and the answer is per MESH, not per tie. +/// It sits immediately after the tie's own vertex block, at TieMetadataOld's 0x18 (see that field: +/// 0x18 is the END offset of the vertex block, not a size - 0x18-0x14 is exactly vertexCount*20 for +/// 186/193 ties), and a mesh's own window begins at 0x18 + TieMesh.verticesIndex * 4. Nothing else +/// is needed: fitting a constant byte delta per tie by brute force returns delta = 0. +/// +/// The long hunt through this file's history for "the other 132 ties" was chasing a bug in the +/// question. Baked lighting is a per-MESH property, so requiring every vertex of a tie to decode +/// in [0,1] fails a tie as soon as ONE of its meshes is unshaded or holds something else: +/// meshes with a valid array here 2082 / 3771 +/// ties where ALL meshes are valid 61 / 193 <- all the old code could see +/// ties where SOME meshes are valid 112 / 193 <- discarded whole, wrongly +/// ties where none are 20 / 193 +/// Those 112 include every tie carrying one of metropolis's 256x256 lightmaps, the largest in the +/// level. Over the scorable valid meshes the area test below gives median r = 0.874 - the same +/// distribution as the ties that already worked, so this is the same data, not a weaker second tier. +/// Validate per mesh (TieReader.SliceLightmapUVs) and the problem is gone. +/// +/// THE RANGE CHECK ALONE IS NOT ENOUGH AT MESH GRANULARITY. Of the 2082 meshes it admits, 259 are +/// all-zero (an unshaded mesh's slot - harmless, they sample texel 0,0 exactly as the game does) and +/// 494 carry enough triangles to test; of those, 16% score below 0.5 on the area test. Those are +/// windows that pass the range check by luck, and they render as the bake REPEATED across the +/// surface, which is what "some ties show the lightmap twice" looks like. TieReader.LooksLikeUnwrap +/// screens them. The failures cluster by tie (18 and 105 are the worst on metropolis), which is what +/// you would expect if those ties' real arrays are somewhere else entirely rather than absent. +/// Blind spot: 1329 admitted meshes have too few triangles to test (34,764 vertices total) and are +/// accepted untested. If a doubled bake survives, look there first. +/// +/// It is NOT the lightmap index. Those were checked directly: 1728 of metropolis's 4848 tie +/// instances carry one, all 1728 distinct, no reuse, and the high 16 bits of the u32 at 0x58 are +/// zero in all 4848. Two instances never share a bake, so a doubled-looking tie is always UVs. +/// +/// THE TEST THAT SETTLES IT is area preservation, not range and not overlap. A lightmap unwrap +/// allocates texels roughly in proportion to world-space surface area, so per triangle, log(UV +/// area) tracks log(3D area). Over the tie's own indexed triangles, at 0x18: +/// real n=47 ties median r = +0.899 40 of 47 above 0.70 +/// control n=60 median r = -0.042 0 of 60 above 0.50 +/// where the control is a random 4-aligned window elsewhere in section 0x9000 of the same length +/// that ALSO passes the range check - i.e. matched on every criterion except being this tie's data. +/// Separation is total. (14 of the 61 are all-zero and drop out of the correlation as degenerate; +/// they are what an unshaded tie's slot looks like. It happens per MESH too, not just per tie - +/// tie 85 has real charts for 18% of its vertices and exact (0,0) for the rest.) +/// +/// Two earlier numbers in this comment were retracted. "1.05 vs 292 triangles per covered texel" +/// compared against a control that did not have to pass the range check, which made it look ~280x +/// more decisive than it is; with a matched control the same metric gives 4.19 vs 10.72, which is +/// suggestive at best. Do NOT use a range test on its own either: decoded as halves, section 0x9000 +/// has 60,000-200,000 four-aligned windows per length entirely inside [0,1], so "all in range" is +/// close to vacuous at blob scale (same trap as UFragVertex.UVs2's u16 reading - see that comment). +/// A distinct-value/quantisation test does not separate them at all. +/// +/// THE OTHER 132 ARE NOT IN SECTION 0x9000 - searched and not found, which is worth knowing before +/// anyone searches it again. Using the area correlation as the search score, every 4-aligned window +/// in the blob was ranked for each tie, restricted to windows that decode fully in-range AND do not +/// overlap any tie's declared vertex block. The search is sound: on ties whose array is known, the +/// true offset ranks #1 out of 80,000-180,000 candidates, 10 times out of 10. Run over the 132 it +/// returns exactly one hit above threshold, at r=0.751 against known-good ties scoring 0.74-0.97 - +/// i.e. indistinguishable from the best of ~150,000 draws from the null distribution. Treat it as +/// nothing found. main.dat was searched the same way for the 12 worst offenders (ties with the most +/// lightmapped instances and no array) and is also clean: best r = 0.436 against known-good ties +/// scoring 0.74-0.97. And there is nowhere else obvious to look - vertices.dat is exactly its two +/// sections plus a 128-byte header, with no slack, and the level has no separate lightmap file. +/// STILL UNTESTED, and the best remaining lead: the same search for an 8-BIT array. The shader +/// admits UNORM8 at location 4 and the draw census splits 51,182 UBYTE against 30,015 half - the +/// same lopsided majority as 132 ties without an array against 61 with one. Only the fixed offset +/// 0x18 was checked as u8 (it fails); a blob-wide u8 search was attempted and abandoned, because the +/// range prefilter that makes the half search tractable is vacuous for u8 (every byte pair is in +/// [0,1] by construction) and the locality prefilter tried instead just selects runs of zeros. +/// Whoever picks this up needs a prefilter that demands real spread AND vertex-to-vertex coherence. +/// So those ties either have no baked lighting at all (consistent with the WWS +/// post-mortem's per-use "what should have shadows" control, and with the per-mesh version of the +/// same thing: tie 85 has real charts for 18% of its vertices and exact (0,0) for the rest), or +/// their UVs are stored per-INSTANCE, or outside this section entirely. An earlier draft of this +/// comment blamed an insufficient gap to the next tie; that reasoning was junk, because UFrag and +/// moby vertices share this blob, so "the gap" was never tie-exclusive in the first place. +/// +/// THE UV ARRAY IS PER-ASSET; THE BAKED TEXTURE IS PER-INSTANCE. These are separate questions and +/// the answers differ, which is easy to conflate. The texture side is settled and per-instance: 1728 +/// of metropolis's 4848 tie instances carry a lightmap index, every one distinct, no reuse (see +/// TieInstance.LightmapIndex). The UV side is per-asset, and the array at 0x18 proves it directly - +/// the gap between a tie's vertex block and the next tie's is exactly vertexCount*4 REGARDLESS of +/// how many instances the tie has. Tie 3 has 310 instances and still gets 4*n bytes; tie 60 has 1 +/// and gets the same. If the array were per-instance the gap would scale with the instance count, +/// and it never does. The size accounting agrees: per-asset for all 193 ties is 3.07 MB, per +/// lightmapped instance is 10.99 MB, and per instance outright is 25.69 MB, in a 27.85 MB section +/// that already spends 15.36 MB on tie vertices. Only the per-asset figure fits alongside 1,987 +/// UFrags and every moby. +/// So one unwrap is shared by every placement of a tie, and each placement gets its own baked +/// texture painted into that shared UV space - which is exactly why ties need no atlas offset the +/// way UFrags do, and why these UVs span the full [0,1] square. +/// +/// WHICH TIES ARE LIT, on metropolis: 105 ties have at least one lightmapped instance but NO UV +/// array at 0x18 - those are the ones that render unlit today and shouldn't. 48 ties have no +/// lightmapped instance at all, and those are genuinely meant to be unlit: the WWS post-mortem's +/// per-use "what should have shadows" control, served by shader-template variants with the lightmap +/// inputs switched off (the captured far/medium tie programs are exactly those - they read tc0.z as +/// a lone scalar and never sample tex4/tex14). Do not treat that second group as a bug. +/// +public readonly record struct TieLightmapUV +{ + /// Bytes per vertex - the RSX attribute's stride. + public const uint Size = 0x04; + + public readonly Half U; + public readonly Half V; + + public TieLightmapUV(Half u, Half v) + { + U = u; + V = v; + } + + /// Reads consecutive pairs starting at + /// , returning them flat as [u0, v0, u1, v1, ...] to match + /// IUFrag.GetLightmapUVs()'s shape. + /// + /// The offset is REQUIRED and deliberately has no default. TieMetadataOld's 0x18 is the right + /// answer for only 61 of 193 ties on metropolis (see the type comment), and defaulting to it + /// would silently decode unrelated blob bytes into plausible-looking in-range UVs for the rest - + /// which is worse than having no lightmap at all, because it renders instead of failing. + /// must be positioned over section 0x9000's stream, big-endian. + public static float[] ReadArray(StreamHelper sh, uint offset, int vertexCount) + { + ArgumentOutOfRangeException.ThrowIfNegative(vertexCount); + + var uvs = new float[vertexCount * 2]; + + sh.Seek(offset); + for (int i = 0; i < vertexCount; i++) + { + uvs[i * 2 + 0] = (float)sh.ReadHalf(); + uvs[i * 2 + 1] = (float)sh.ReadHalf(); + } + + return uvs; + } +} diff --git a/ReLunacy.Engine/Loading/Vertices/UFragVertex.cs b/ReLunacy.Engine/Loading/Vertices/UFragVertex.cs index ddb8f26..856ad06 100644 --- a/ReLunacy.Engine/Loading/Vertices/UFragVertex.cs +++ b/ReLunacy.Engine/Loading/Vertices/UFragVertex.cs @@ -9,14 +9,22 @@ public record struct UFragVertex public (short, short, short) position; public short unk; + + // Same field role, same decode, as VertexFormat0.boneIndex/VertexAlphaCandidate: a SINT16 + // immediately after position (confirmed by the RSX attribute descriptor below - attr0 is a + // genuine 4-component SINT16 attribute, not two unrelated reads), on a mesh type with no + // skeleton at all - UFrags have no bones, so there is nothing else this field could be doing + // its nominal job as here, same reasoning that applies on Ties. + public readonly float VertexAlphaCandidate => Math.Clamp((0xC000 - (ushort)unk) / 127f, 0f, 1f); + public (Half, Half) UVs; - /// LIGHTMAP UVs — atlas coordinates into the zone's baked light colour / direction + /// LIGHTMAP UVs - atlas coordinates into the zone's baked light colour / direction /// textures (sections 0x5400 / 0x5410). HALF-FLOATS, same as the base UVs above. /// /// This is not inferred, it is read off the hardware: RPCS3's captured DrawParametersBuffer /// (set 0, binding 2) for a metropolis UFrag draw describes the vertex stream attribute by - /// attribute, and 2498 draws in that frame carry exactly this shape — + /// attribute, and 2498 draws in that frame carry exactly this shape - /// attr0 stride=24 offset=+0 SINT16 x4 -> position + /// attr1 stride=24 offset=+8 SFLOAT16 x2 -> /// attr2 stride=24 offset=+12 SFLOAT16 x2 -> THIS FIELD @@ -24,12 +32,12 @@ public record struct UFragVertex /// attr4 stride=24 offset=+20 CMP 11:11:10 -> /// all five in one non-volatile stream with swap_bytes set (big-endian source), which is this /// struct field for field. The game's vertex program routes attr2 straight into tc0.zw, and its - /// fragment program samples BOTH baked atlases — tex4 (0x5400) and tex14 (0x5410) — at tc0.zw. + /// fragment program samples BOTH baked atlases - tex4 (0x5400) and tex14 (0x5410) - at tc0.zw. /// So attr2 is the lightmap UV, and RSX type 3 is a half float (elem size 2, scale 1.0). /// /// An earlier reading here as normalised u16 was wrong, and wrong in a way that looked fine: /// u16/65535 is unconditionally inside [0,1], so "100% in range" confirmed nothing. Measured - /// over the 1377 lightmapped UFrags of metropolis, the two decodes separate cleanly — + /// over the 1377 lightmapped UFrags of metropolis, the two decodes separate cleanly - /// as u16/65535 : median island extent 0.008 x 0.009, atlas coverage 2.5% /// as half : median island extent 0.148 x 0.180, atlas coverage 88.6% /// A packed atlas is nearly fully covered by construction, so 2.5% alone falsifies the u16 @@ -39,23 +47,23 @@ public record struct UFragVertex /// the real indexed triangles into each atlas and measure how often two different UFrags claim /// the same texel: 0.3% mean, exactly 0.0% on 9 of the 23 atlases, at 61.7% mean coverage. 1377 /// independently unwrapped fragments do not pack into 23 shared atlases without colliding by - /// accident, and it also rules out a missing per-UFrag sub-rect — there is no room left for one. + /// accident, and it also rules out a missing per-UFrag sub-rect - there is no room left for one. /// (Do not use bounding-box overlap for this. Roughly 60 UFrags per atlas at a median bbox of /// 0.148 x 0.180 sums to about 160% of the atlas, so bbox overlap reads ~75% no matter whether /// the decode is right. It measures the boxes, not the packing.) /// - /// Halves land 98.3% inside [0,1], and the 1.7% is NOT sampler edge bleed — it is bimodal. Per + /// Halves land 98.3% inside [0,1], and the 1.7% is NOT sampler edge bleed - it is bimodal. Per /// UFrag, 1354 are wholly in range and 23 are wholly out, with nothing in between. Those 23 /// carry one constant on every single vertex, u = -0.0 (half 0x8000) and v = 18.344 (half /// 0x4C96), across 2 atlases and 7 different shaders. That is a never-written UV2 slot, not a /// coordinate: whatever the addressing mode, it resolves to an arbitrary atlas edge pixel. So /// those 23 are expected to render with a wrong flat tint and are candidates for being treated - /// as unlit outright — deliberately NOT done here, since it is a rendering policy rather than a + /// as unlit outright - deliberately NOT done here, since it is a rendering policy rather than a /// decode fact, and silently hiding them would also hide the next thing that produces them. /// /// The reason the half decode was originally dismissed as "nonsense, values like 420.75" is /// that it was measured across the whole of vertices.dat section 0x9000 at stride 24. That - /// section is one shared blob holding moby and tie vertices too, at other strides — so most of + /// section is one shared blob holding moby and tie vertices too, at other strides - so most of /// those reads were straddling unrelated records. Restricted to the byte ranges the UFrag /// metadata actually points at, the same decode is 100% finite. The control that would have /// caught it earlier is cheap: the base UVs at +8 are known-good halves, and they score 86% @@ -68,11 +76,11 @@ public record struct UFragVertex public UFragVertex(StreamHelper sh) { - // Offsets below are relative to THIS vertex's own record start, not the file's — + // Offsets below are relative to THIS vertex's own record start, not the file's - // StreamHelper's ReadXxx(offset)/Seek(offset) all seek absolutely from the start of the // stream, so the record's actual position has to be added in. Without this, every vertex // past the first in a UFrag reads from the wrong place in the file entirely (same bug - // class as UFragMetadata — see its constructor comment). + // class as UFragMetadata - see its constructor comment). uint recordBase = (uint)sh.Offset; position.Item1 = sh.ReadInt16(recordBase + 0x00); @@ -82,7 +90,7 @@ public UFragVertex(StreamHelper sh) sh.Seek(recordBase + 0x08); UVs.Item1 = sh.ReadHalf(); UVs.Item2 = sh.ReadHalf(); - // Halves, matching the RSX attribute descriptor — see the field comment. + // Halves, matching the RSX attribute descriptor - see the field comment. UVs2.Item1 = (float)sh.ReadHalf(); UVs2.Item2 = (float)sh.ReadHalf(); normal = sh.ReadUInt32(recordBase + 0x10); diff --git a/ReLunacy.Engine/Loading/Vertices/VertexFormat0.cs b/ReLunacy.Engine/Loading/Vertices/VertexFormat0.cs index 4006525..f8f1bd5 100644 --- a/ReLunacy.Engine/Loading/Vertices/VertexFormat0.cs +++ b/ReLunacy.Engine/Loading/Vertices/VertexFormat0.cs @@ -19,13 +19,26 @@ public record struct VertexFormat0 // Confirmed against real data (user-supplied alpha=0/0.5/1.0 samples on an Overlay-mode Tie // mesh): boneIndex's raw bits, reinterpreted as unsigned, decode as a 7-bit alpha subtracted - // from a fixed base — raw = 0xC000 - round(127*alpha). Fit from the two endpoints (alpha 0 and + // from a fixed base - raw = 0xC000 - round(127*alpha). Fit from the two endpoints (alpha 0 and // 1) correctly predicted the observed midpoint (alpha 0.5 -> 0xBFC0), which is real // confirmation, not just 3 points trivially fitting a 2-parameter line. Still unconfirmed // whether this interpretation applies unconditionally, or only when a not-yet-found shader- // level flag says to read this field as alpha instead of a real bone index (see - // Material.UsesVertexAlphaCandidate for the current best-known gating condition) — this is + // Material.UsesVertexAlphaCandidate for the current best-known gating condition) - this is // just the raw decode, callers decide when it's meaningful. + // WHAT THE GAME ACTUALLY DOES WITH THIS FIELD ON TIES, from its own vertex program + // (dev/ties/ties_vertex_shader_LOD0.glsl). It is read twice, and neither read is a bone index: + // r3.xy = fract(abs(in_pos.wwww) * vc[1].zw) * vc[7].zw; -> tc1.xy + // r2.w = sign(in_pos.wwww); -> tangent handedness + // The first is a UV PAIR bit-packed into one int16 and unpacked by two different scale factors + // plus fract() - almost certainly the detail-map coordinates, matching the tie fragment + // programs' `tc1.x != 0` gate. The second flips the tangent (r5 = tangent * r2.w) before the + // cross product that builds the binormal in tc5, i.e. it carries mirrored-UV handedness. + // That does not automatically retract VertexAlphaCandidate below - that decode was confirmed + // against user-supplied alpha 0/0.5/1.0 samples and predicted the midpoint - but the two + // readings are in tension and cannot both be the field's purpose on ties. A plausible + // reconciliation is that the observed "alpha" was really the packed value's low bits driving + // detail-map placement; that has NOT been tested. Do not build on either reading alone. public readonly float VertexAlphaCandidate => Math.Clamp((0xC000 - (ushort)boneIndex) / 127f, 0f, 1f); public VertexFormat0(StreamHelper sh) @@ -42,13 +55,13 @@ public VertexFormat0(StreamHelper sh) public readonly override string ToString() => $"Pos: ({position.Item1}; {position.Item2}; {position.Item3}) UVs: ({UVs.Item1}; {UVs.Item2})"; - // For the Shader/Mesh raw-vertex inspector — every field this format has, raw and decoded. + // For the Shader/Mesh raw-vertex inspector - every field this format has, raw and decoded. // Labeled "vertexAttribute" rather than "boneIndex" here: on Mobys with a skeleton, this field // genuinely is the bone index (the field keeps that C# name since that's its real job there), // but on Tie meshes (which have no skeleton at all, so it can't be doing its nominal job) it's // the single most plausible remaining place for a per-vertex color/alpha value to be hiding, // now that normal/tangent are both confirmed to fully consume their 32 bits as pure direction - // data with zero bits to spare — this one field pulls double (or more) duty depending on the + // data with zero bits to spare - this one field pulls double (or more) duty depending on the // mesh, so the inspector describes it generically instead of implying it's always a bone index. public readonly string Dump() => $"Position (raw int16): ({position.Item1}, {position.Item2}, {position.Item3})\n" + diff --git a/ReLunacy.Engine/Loading/Vertices/VertexFormat1.cs b/ReLunacy.Engine/Loading/Vertices/VertexFormat1.cs index a6453ac..129f0b2 100644 --- a/ReLunacy.Engine/Loading/Vertices/VertexFormat1.cs +++ b/ReLunacy.Engine/Loading/Vertices/VertexFormat1.cs @@ -43,7 +43,7 @@ public VertexFormat1(StreamHelper sh) public readonly override string ToString() => $"Pos: ({position.Item1}; {position.Item2}; {position.Item3}) UVs: ({UVs.Item1}; {UVs.Item2})"; // For the Shader/Mesh raw-vertex inspector. Unlike VertexFormat0's boneIndex, bones/weights - // here are already meaningfully used for skinning — Unk1 (int16, right after position) is + // here are already meaningfully used for skinning - Unk1 (int16, right after position) is // this format's own unidentified spare field, and the closest candidate to a vertex color/ // alpha value if one exists on skinned meshes. public readonly string Dump() => diff --git a/ReLunacy.Engine/ReLunacy.Engine.csproj b/ReLunacy.Engine/ReLunacy.Engine.csproj index 02f1ca3..cbe8ca4 100644 --- a/ReLunacy.Engine/ReLunacy.Engine.csproj +++ b/ReLunacy.Engine/ReLunacy.Engine.csproj @@ -9,13 +9,33 @@ - + + + + + + + + + + + + PreserveNewest + Shaders\%(RecursiveDir)%(Filename)%(Extension) + + + diff --git a/ReLunacy.Engine/Rendering/AssetManager.cs b/ReLunacy.Engine/Rendering/AssetManager.cs index b34cee8..250db67 100644 --- a/ReLunacy.Engine/Rendering/AssetManager.cs +++ b/ReLunacy.Engine/Rendering/AssetManager.cs @@ -1,95 +1,126 @@ using System.Numerics; -using Bliss.CSharp; -using Bliss.CSharp.Colors; -using Bliss.CSharp.Effects; -using Bliss.CSharp.Graphics; -using Bliss.CSharp.Geometry.Meshes; -using Bliss.CSharp.Geometry.Meshes.Data; -using Bliss.CSharp.Geometry.Models; -using Bliss.CSharp.Graphics.Pipelines.Buffers; -using Bliss.CSharp.Graphics.VertexTypes; -using Bliss.CSharp.Images; -using Bliss.CSharp.Materials; -using Bliss.CSharp.Textures; using ReLunacy.Engine.Assets.Interfaces; using ReLunacy.Engine.Loading.Readers; -using ReLunacy.Engine.Rendering.Shaders; +using ReLunacy.Engine.Rendering.Resources; +using ReLunacy.Engine.Rendering.Vulkan; +using ReLunacy.Engine.Scene; using Veldrith; -using Veldrith.SPIRV; using IMesh = ReLunacy.Engine.Assets.Interfaces.IMesh; -using RenderMode = Bliss.CSharp.Graphics.Rendering.RenderMode; namespace ReLunacy.Engine.Rendering; -// Builds Bliss GPU resources (Mesh/Model/Material/Texture2D) from the engine-owned asset model -// (Assets.Mobys.Moby / Assets.Ties.Tie / their meshes' IMaterial/ITexture), caching by TUID so -// shared materials/textures aren't rebuilt per mesh. +// Builds renderer-side resources (RenderMesh/RenderModel/RenderMaterial/GpuTexture) from the +// engine-owned asset model (Assets.Mobys.Moby / Assets.Ties.Tie / their meshes' IMaterial/ITexture), +// caching by TUID so shared materials/textures aren't rebuilt per mesh. public sealed class AssetManager : IDisposable { private readonly GraphicsDevice _gd; - private readonly Dictionary _textureCache = []; + + /// The whole level's geometry/materials/textures, captured once and replayed every frame + /// by whichever 3D view needs it. Lives here, not on a DockedFrame, specifically so closing and + /// reopening the 3D View panel does not force re-uploading the entire level to the GPU: this + /// object's lifetime matches the LEVEL (constructed with the rest of AssetManager, disposed by + /// on level unload), not any particular panel's open/closed + /// state. The panel still owns calling into it every frame (Frame/SubmitFrame/Pick/Resize) - only + /// the expensive captured GPU state moved, not who drives it or when. + public VulkanRenderer? SceneRenderer { get; private set; } + + private readonly Dictionary _textureCache = []; private readonly Dictionary _sourceTextures = []; // Keyed on (shader TUID, lightmap index), NOT on the TUID alone. Baked lighting is a - // per-INSTANCE property — one shader is shared across many UFrags, each with its own entry in - // the zone's 0x5400/0x5410 lists — while Bliss binds textures through the Material. Caching by + // per-INSTANCE property - one shader is shared across many UFrags, each with its own entry in + // the zone's 0x5400/0x5410 lists - while Bliss binds textures through the Material. Caching by // TUID alone would therefore give every instance whichever lightmap happened to be built // first. Materials with no baked lighting all collapse onto UFragMetadata.NoLightmap, so // nothing that existed before this distinction pays for it. - private readonly Dictionary<(ulong ShaderId, ushort LightmapIndex), Material> _materialCache = []; + private readonly Dictionary<(ulong ShaderId, ushort LightmapIndex), RenderMaterial> _materialCache = []; // Every built variant of a given shader TUID. The live-tuning API (SetParallax, - // SetDetailStrengths) is addressed by TUID because that is what the ShaderBrowser lists, so it + // SetDetailTiling) is addressed by TUID because that is what the ShaderBrowser lists, so it // has to reach all of a shader's lightmap variants rather than just one. - private readonly Dictionary> _materialsByShader = []; - // Parallel to _materialCache, keyed the same way — the built Material has no way to ask "was - // I sourced from a vertex-alpha material," but SetLightingEnabled needs that to pick the right - // effect when swapping back to unlit, so the original IMaterial is kept alongside the built one. + private readonly Dictionary> _materialsByShader = []; + // Parallel to _materialCache, keyed by shader TUID: the built material keeps none of the source + // metadata, and the live-tuning API needs it back (see TryGetDetailTiling, which hides its control + // for a shader that has no detail texture at all). private readonly Dictionary _sourceMaterials = []; - private bool _backfaceCulling; - private bool _lightingEnabled; - // Scene-wide default filtering plus per-texture overrides (keyed by texture TUID) — samplers + // Per-built-material data for the raw-Vulkan renderer: the game's own rendering mode (0-6), + // whether vertex alpha was decoded for it, and whether its albedo's own alpha is real. A side + // table rather than three more map slots, because none of them is a texture and the renderer + // wants them as one lookup. + private readonly Dictionary _vkMaterialInfo = + new(ReferenceEqualityComparer.Instance); + + // Foliage billboard materials: the set is what IsBillboardMaterial answers from, the cache is what + // keeps one Material per source shader instead of one per placement. + private readonly HashSet _billboardMaterials = new(ReferenceEqualityComparer.Instance); + private readonly Dictionary _billboardMaterialCache = new(); + + /// The game's rendering mode (0 Opaque, 1 Overlay, 2 Additive, 3 Scunge, 4 Cutout, + /// 5 Soft-Edge, 6 Blended) and vertex-alpha flag for a built material. See IMaterial.GameRenderMode + /// and dev/chatgpt-eboot-1..3.txt for what each mode's real RSX state is. + public bool TryGetVkMaterialInfo(RenderMaterial bMat, out byte gameRenderMode, out bool usesVertexAlpha, out bool albedoHasAlphaChannel) + { + if (_vkMaterialInfo.TryGetValue(bMat, out var info)) + { + (gameRenderMode, usesVertexAlpha, albedoHasAlphaChannel) = info; + return true; + } + gameRenderMode = 0; + usesVertexAlpha = false; + albedoHasAlphaChannel = false; + return false; + } + // Scene-wide default filtering plus per-texture overrides (keyed by texture TUID) - samplers // resolve through GetSamplerFor in exactly one place, so future filtering techniques are one - // new TextureFiltering value + one switch arm. _builtTextureIds is the Texture2D -> TUID + // new TextureFiltering value + one switch arm. _builtTextureIds is the GpuTexture -> TUID // reverse of _textureCache, needed to re-resolve an already-built MaterialMap's sampler live // (the map only holds the GPU texture, not the id it was built from). private TextureFiltering _defaultTextureFiltering = TextureFiltering.Point; private readonly Dictionary _perTextureFiltering = []; - private readonly Dictionary _builtTextureIds = []; - private Effect? _vertexAlphaModelEffect; - private Effect? _litModelEffect; - // Flat "no perturbation" fallback for LitModelShaderSource's normal map slot, which every - // material now gets a MaterialMap entry for (see GetOrBuildMaterial) even when the source has - // no NormalTexture at all — the lit effect's texture layout expects something bound there - // regardless, same reason Albedo already falls back to GlobalResource.DefaultModelTexture. + private readonly Dictionary _builtTextureIds = []; + // Flat "no perturbation" fallback for the normal map slot, which every material gets an entry for + // (see GetOrBuildMaterial) even when the source has no NormalTexture at all: the renderer samples + // every slot unconditionally, same reason Albedo falls back to GetDefaultAlbedoTexture. // G=128, A=128 decodes to dx=dy=0 under LitModelShaderSource's derivative reconstruction, - // i.e. a perfectly flat tangent-space normal (0,0,1) — R/B are unused by that reconstruction, + // i.e. a perfectly flat tangent-space normal (0,0,1) - R/B are unused by that reconstruction, // so their value doesn't matter. - private Texture2D? _defaultNormalTexture; + private GpuTexture? _defaultAlbedoTexture; + private GpuTexture? _defaultNormalTexture; // See GetDefaultPropertiesTexture. - private Texture2D? _defaultPropertiesTexture; + private GpuTexture? _defaultPropertiesTexture; // See GetDefaultLightColourTexture / GetDefaultLightDirTexture. - private Texture2D? _defaultLightColourTexture; - private Texture2D? _defaultLightDirTexture; + private GpuTexture? _defaultLightColourTexture; + private GpuTexture? _defaultLightDirTexture; - // Veldrith's RasterizerStateDescription.DEFAULT assumes a clockwise front face; this game's - // meshes wind the opposite way, so using DEFAULT as-is culled the near side of every triangle - // and left the far side visible (backface culling looked "inside out" — confirmed by the user - // after enabling it). Same CullMode.Back as DEFAULT, just the winding flipped. - private static readonly RasterizerStateDescription BackfaceCullState = new( - FaceCullMode.Back, PolygonFillMode.Solid, FrontFace.CounterClockwise, true, false); + // The non-opaque polygon offset the game applies (depth bias -87, slope-scaled -0.33972, read + // out of a RenderDoc capture) used to be built into a RasterizerStateDescription here. It now + // lives with the renderer that applies it, as VulkanRenderer.NonOpaqueDepthBias, along with the + // full derivation of where those two numbers come from. - public IReadOnlyDictionary BuiltTextures => _textureCache; + public IReadOnlyDictionary BuiltTextures => _textureCache; public IReadOnlyDictionary SourceTextures => _sourceTextures; - public Dictionary Mobys { get; } = []; // one Model per bangle - public Dictionary Ties { get; } = []; + public Dictionary Mobys { get; } = []; // one Model per bangle + public Dictionary Ties { get; } = []; + + // Decoded textures waiting to be uploaded, filled by PrepareTextures before anything is built. + // Entries are REMOVED as they are consumed, so the decoded pixels are freed as the build walks + // past them rather than being held for the whole load. A present-but-null entry means the decode + // was attempted and failed, which is different from never having been prepared. + private readonly System.Collections.Concurrent.ConcurrentDictionary _prepared = new(); - public AssetManager(LevelData level, GraphicsDevice gd) + /// Textures already decoded by , ideally + /// on the loading task so this constructor never pays for them. Null decodes them here instead, + /// which is correct but blocks whatever thread this runs on. + public AssetManager(LevelData level, GraphicsDevice gd, IDictionary? preparedTextures = null) { _gd = gd; + var sw = System.Diagnostics.Stopwatch.StartNew(); long t0 = 0; + + foreach (var (id, levels) in preparedTextures ?? PrepareTextures(level)) _prepared[id] = levels; foreach (var (id, moby) in level.Mobys) { - var models = new Model[moby.Bangles.Count]; + var models = new RenderModel[moby.Bangles.Count]; for (int i = 0; i < moby.Bangles.Count; i++) { models[i] = BuildModel(moby.Bangles[i].Meshes); @@ -97,13 +128,15 @@ public AssetManager(LevelData level, GraphicsDevice gd) Mobys[id] = models; } + long tMobys = sw.ElapsedMilliseconds - t0; t0 = sw.ElapsedMilliseconds; foreach (var (id, tie) in level.Ties) { Ties[id] = BuildModel(tie.Meshes); } + long tTies = sw.ElapsedMilliseconds - t0; t0 = sw.ElapsedMilliseconds; // Build every texture the level loaded, not just the ones the loops above already pulled - // in via a used material — some textures.dat/highmips.dat entries aren't wired to any + // in via a used material - some textures.dat/highmips.dat entries aren't wired to any // shader used by this level's geometry (cut/unused content), but are still worth being // able to see/export. A single bad one (unexpected dimensions/corrupt data) shouldn't take // the rest of the level down with it. @@ -119,15 +152,128 @@ public AssetManager(LevelData level, GraphicsDevice gd) } } + long tTextures = sw.ElapsedMilliseconds - t0; t0 = sw.ElapsedMilliseconds; ZoneLightmaps = BuildZoneLighting(level.ZoneLightmaps, "lightmap"); ZoneDirectionals = BuildZoneLighting(level.ZoneDirectionals, "directional"); if (ZoneLightmaps.Count != 0) Console.WriteLine($"Zone lighting: {ZoneLightmaps.Count} light-colour and {ZoneDirectionals.Count} light-direction textures built."); + + long tZone = sw.ElapsedMilliseconds - t0; + BuildEnvironmentCubemap(level); + if (!_prepared.IsEmpty) + Console.WriteLine($"Warning: {_prepared.Count} decoded textures were prepared but never built - they are holding memory for nothing."); + // Timings kept because load time is a feature here and these are what showed where it went. + // GPU upload no longer happens in here at all (see GetOrBuildTexture/UploadOnePendingTexture) - + // these numbers are now CPU-only mesh/material building, which is why they're small even on a + // level with thousands of materials; TotalQueuedUploads is what the caller drains afterwards. + Console.WriteLine($"Assets built in {sw.ElapsedMilliseconds}ms (mobys {tMobys}, ties {tTies}, textures {tTextures}, zone lighting {tZone}). {TotalQueuedUploads} textures queued for GPU upload."); + } + + /// Decodes every texture the level is about to need, in parallel, before a single one is + /// uploaded. + /// + /// This is the whole point of the exercise. Decoding and mip generation were ~6.7s of the ~10.5s + /// the asset build spent frozen on metropolis, and both are plain arithmetic over byte arrays. + /// What is left on this thread afterwards is the GPU upload, which cannot move. + /// + /// Safe to run concurrently because an ITexture's pixels are already in memory by this point: the + /// file reads all happened on the loading task, and Texture.GetPixelData just hands back the array + /// its loader closed over. The block decoders are shared statics but hold only readonly + /// configuration, so they have no state to race on. + public static Dictionary PrepareTextures(LevelData level) + { + // Atlases are built without mips: averaging neighbouring texels across an island boundary pulls + // the black gutters inward. Collected first so the decode below knows which is which. + var withoutMips = new HashSet(); + foreach (var texture in level.ZoneLightmaps) if (texture != null) withoutMips.Add(texture.Id); + foreach (var texture in level.ZoneDirectionals) if (texture != null) withoutMips.Add(texture.Id); + + var needed = new Dictionary(); + void Need(ITexture? texture) { if (texture != null) needed.TryAdd(texture.Id, texture); } + void NeedMaterial(IMaterial? material) + { + if (material == null) return; + Need(material.AlbedoTexture); + Need(material.NormalTexture); + Need(material.PropertiesTexture); + Need(material.DetailTexture); + } + + foreach (var texture in level.AllTextures.Values) Need(texture); + foreach (var texture in level.ZoneLightmaps) Need(texture); + foreach (var texture in level.ZoneDirectionals) Need(texture); + foreach (var (_, moby) in level.Mobys) + foreach (var bangle in moby.Bangles) + foreach (var mesh in bangle.Meshes) NeedMaterial(mesh.Material); + foreach (var (_, tie) in level.Ties) + foreach (var mesh in tie.Meshes) NeedMaterial(mesh.Material); + + var prepared = new System.Collections.Concurrent.ConcurrentDictionary(); + Parallel.ForEach(needed.Values, texture => + { + try + { + byte[]? rgba = TextureUtils.DecodeToRgba8888(texture, out int width, out int height); + prepared[texture.Id] = rgba == null + ? null + : TextureLevels.Prepare((uint)width, (uint)height, rgba, !withoutMips.Contains(texture.Id)); + } + catch (Exception ex) + { + // Recorded as a failure rather than rethrown: one unreadable texture must not take the + // level down, and GetOrBuildTexture substitutes the default for a null entry exactly + // as it does for a texture that decodes to nothing. + prepared[texture.Id] = null; + Console.WriteLine($"Warning: Failed to decode texture {texture.Id:X}: {ex.Message}"); + } + }); + return new Dictionary(prepared); + } + + // The level's environment cubemap as a GPU samplerCube, for the lit shader's reflection term. + // Always non-null once constructed: a level with no cubemap gets a 1x1 grey fallback so the lit + // effect's declared set 10 is never bound to nothing (an unbound descriptor set is undefined + // behaviour - the same class of fault BuildLitModelEffect documents). See CubemapReader for the + // face format and LitModelShaderSource for how it's sampled. + private Veldrith.Texture? _environmentCubemap; + public Veldrith.TextureView? EnvironmentCubemapView { get; private set; } + + private void BuildEnvironmentCubemap(LevelData level) + { + var cubemap = level.Cubemaps.Count > 0 ? level.Cubemaps[0] : null; + int size = cubemap?.FaceSize ?? 1; + var factory = _gd.ResourceFactory; + + var tex = factory.CreateTexture(Veldrith.TextureDescription.Texture2D( + (uint)size, (uint)size, 1, 6, Veldrith.PixelFormat.R8G8B8A8UNorm, + Veldrith.TextureUsage.Sampled | Veldrith.TextureUsage.Cubemap)); + + // Face order is the file's own +X,-X,+Y,-Y,+Z,-Z, which is exactly the cube array-layer + // order Vulkan expects, so layer index == face index with no remap. + for (uint f = 0; f < 6; f++) + { + byte[] rgba = (cubemap != null && f < cubemap.Faces.Count + ? TextureUtils.DecodeToRgba8888(cubemap.Faces[(int)f], out _, out _) + : null) ?? FallbackCubeFace(size); + _gd.UpdateTexture(tex, rgba, 0, 0, 0, (uint)size, (uint)size, 1, 0, f); + } + + _environmentCubemap = tex; + EnvironmentCubemapView = factory.CreateTextureView(tex); + } + + private static byte[] FallbackCubeFace(int size) + { + // Mid-grey, mid-alpha. Only ever sampled when a real cubemap is absent, in which case the + // renderer's EnvironmentIntensity is 0 and this contributes nothing regardless. + var data = new byte[size * size * 4]; + Array.Fill(data, (byte)128); + return data; } /// Baked lighting is ON for TERRAIN. UFrags index a shared atlas via /// UFragMetadata.lightmapIndex (old-engine offset 0x4E) and sample it through - /// UFragVertex.UVs2, which are half-float atlas coordinates — 1377 of metropolis's 1987 + /// UFragVertex.UVs2, which are half-float atlas coordinates - 1377 of metropolis's 1987 /// UFrags are lit this way across 23 atlases. /// TIES are still excluded (EntityTie pins them to NoLightmap): they carry a real per-instance /// bake index, but VertexFormat0 has one UV pair and it TILES, so there is nothing correct to @@ -137,14 +283,14 @@ public AssetManager(LevelData level, GraphicsDevice gd) public const bool EnableBakedLighting = true; /// Positionally indexed by an instance's lightmap index (see - /// TieInstance.LightmapIndex), so a failed entry becomes null rather than being dropped — + /// TieInstance.LightmapIndex), so a failed entry becomes null rather than being dropped - /// removing it would shift every later index onto the wrong texture. - public IReadOnlyList ZoneLightmaps { get; } = []; - public IReadOnlyList ZoneDirectionals { get; } = []; + public IReadOnlyList ZoneLightmaps { get; } = []; + public IReadOnlyList ZoneDirectionals { get; } = []; - private List BuildZoneLighting(IReadOnlyList source, string label) + private List BuildZoneLighting(IReadOnlyList source, string label) { - var built = new List(source.Count); + var built = new List(source.Count); foreach (var tex in source) { try @@ -160,76 +306,57 @@ public AssetManager(LevelData level, GraphicsDevice gd) return built; } - private Model BuildModel(IReadOnlyList meshes) + private RenderModel BuildModel(IReadOnlyList meshes) { - var bMeshes = new Bliss.CSharp.Geometry.Meshes.IMesh[meshes.Count]; + var bMeshes = new RenderMesh[meshes.Count]; for (int i = 0; i < meshes.Count; i++) { var mesh = meshes[i]; var material = GetOrBuildMaterial(mesh.Material); var vertices = ConvertGeometryToVertices(mesh.Geometry, mesh.Material.UsesVertexAlphaCandidate); - bMeshes[i] = new Mesh(_gd, material, new BasicMeshData(vertices, mesh.Geometry.GetIndices())); + var indices = mesh.Geometry.GetIndices(); + + var bMesh = new RenderMesh(vertices, indices, material); + + // Register this mesh's raw geometry (interleaved, plus indices) keyed by the mesh instance, + // so the renderer can upload it once and resolve every scene instance back to it. + if (vertices.Length > 0 && indices.Length >= 3) + Vulkan.VulkanSceneCapture.Register(bMesh, Vulkan.VulkanSceneCapture.Interleave(vertices), indices); + + bMeshes[i] = bMesh; } - return new Model(_gd, bMeshes, null, []); + return new RenderModel(bMeshes); } /// lightmapIndex: this instance's entry in the zone's baked-lighting lists (see /// IUFrag.LightmapIndex). Part of the cache key because baked lighting is per-instance while /// Bliss binds textures per-Material. Callers with no baked lighting omit it. - public Material GetOrBuildMaterial(IMaterial material, ushort lightmapIndex = Loading.Objects.UFragMetadata.NoLightmap) + public RenderMaterial GetOrBuildMaterial(IMaterial material, ushort lightmapIndex = Loading.Objects.UFragMetadata.NoLightmap) { var cacheKey = (material.Id, lightmapIndex); if (_materialCache.TryGetValue(cacheKey, out var cached)) return cached; - var renderMode = material.RenderMode switch - { - Assets.Interfaces.RenderMode.AlphaClip => RenderMode.Cutout, - Assets.Interfaces.RenderMode.AlphaBlend => RenderMode.Translucent, - // Bliss's own RenderMode has no Additive case — Translucent is the closest bucket - // (same depth-test-no-write handling via DecalAwareForwardRenderer), the real - // distinction is the blend state passed below. - Assets.Interfaces.RenderMode.Additive => RenderMode.Translucent, - _ => RenderMode.Solid, - }; - - // Standard "over" alpha blend for everything that blends, including vertex-alpha-fallback - // materials (see Material.UsesVertexAlphaCandidate) — additive and Screen were both tried - // here and reverted; the darkening/brightening those were chasing turned out to be this - // renderer being unlit (no specular/lighting response the real game has), not a wrong blend - // equation. Standard alpha blend is correct; the visual mismatch is a lighting gap to close - // separately, later. - BlendStateDescription? blendState = material.RenderMode switch - { - Assets.Interfaces.RenderMode.Additive => BlendStateDescription.SINGLE_ADDITIVE_BLEND, - Assets.Interfaces.RenderMode.AlphaBlend => BlendStateDescription.SINGLE_ALPHA_BLEND, - _ => null, - }; - - var bMat = new Material( - SelectEffect(material), - _backfaceCulling ? BackfaceCullState : RasterizerStateDescription.CULL_NONE, - blendState, - renderMode); + var bMat = new RenderMaterial(); // value = the game's own per-material alphaClip threshold (maps[0].value in - // LitModelShaderSource's Cutout branch) — the same field GltfExporter already trusts for + // LitModelShaderSource's Cutout branch) - the same field GltfExporter already trusts for // glTF's alphaCutoff. Only meaningful for Cutout materials; harmless elsewhere. - var albedo = material.AlbedoTexture != null ? GetOrBuildTexture(material.AlbedoTexture) : GlobalResource.DefaultModelTexture; - bMat.AddMaterialMap(new MaterialMapKey(MaterialMapType.Albedo), 0, new MaterialMap(albedo, ResolveSampler(albedo), color: Color.White, value: material.AlphaClipThreshold)); + var albedo = material.AlbedoTexture != null ? GetOrBuildTexture(material.AlbedoTexture) : GetDefaultAlbedoTexture(); + bMat.AddMaterialMap(MaterialMapType.Albedo, new MaterialMap(albedo, ResolveSampler(albedo), material.AlphaClipThreshold)); // Always added (not conditional on NormalTexture existing) so LitModelShaderSource's - // texture layout always has something bound to it once lighting is enabled — see + // texture layout always has something bound to it once lighting is enabled - see // GetDefaultNormalTexture. Harmless for the unlit effects, which don't declare a Normal // texture layout at all, so this entry is just never looked up by anything. // This map's value slot is unrelated to the normal texture: it flags whether the expensive - // map has a real alpha channel to read the detail mask from — see the detail-map section + // map has a real alpha channel to read the detail mask from - see the detail-map section // below for why, and LitModelShaderSource's maps[1].value. var normal = material.NormalTexture != null ? GetOrBuildTexture(material.NormalTexture) : GetDefaultNormalTexture(); bool detailMaskFromTexture = material.PropertiesTexture != null && HasAlphaChannel(material.PropertiesTexture.Format); - bMat.AddMaterialMap(new MaterialMapKey(MaterialMapType.Normal), 1, new MaterialMap(normal, ResolveSampler(normal), value: detailMaskFromTexture ? 1f : 0f)); + bMat.AddMaterialMap(MaterialMapType.Normal, new MaterialMap(normal, ResolveSampler(normal), detailMaskFromTexture ? 1f : 0f)); - // "fProperties" (a custom name, not one of Bliss's built-in MaterialMapType slots — none of + // "fProperties" (a custom name, not one of Bliss's built-in MaterialMapType slots - none of // Metallic/Roughness/Emission etc. individually match what this actually is) is this game's // packed "expensive" intensity texture. Layout confirmed against the game's own captured // fragment shader (see fragment_shader_annotated.glsl): R=specular intensity, @@ -240,12 +367,12 @@ public Material GetOrBuildMaterial(IMaterial material, ushort lightmapIndex = Lo // This map's own value slot is unrelated to the texture: it carries the DETAIL UV TILING // (maps[2].value in LitModelShaderSource), which had nowhere better to live once all 8 // MaterialMap slots were spoken for. See the shader for why the game has no fragment - // constant to read it from — the tiling is baked into a vertex interpolant there. + // constant to read it from - the tiling is baked into a vertex interpolant there. // A tiling of literally 0 would collapse the detail map to one texel, so it can't be what - // the field means — treat it as "not present" and fall back to 1 (base-map frequency). + // the field means - treat it as "not present" and fall back to 1 (base-map frequency). var properties = material.PropertiesTexture != null ? GetOrBuildTexture(material.PropertiesTexture) : GetDefaultPropertiesTexture(); float detailTiling = material.DetailTiling != 0f ? material.DetailTiling : DefaultDetailTiling; - bMat.AddMaterialMap(new MaterialMapKey("fProperties"), 2, new MaterialMap(properties, ResolveSampler(properties), value: detailTiling)); + bMat.AddMaterialMap("fProperties", new MaterialMap(properties, ResolveSampler(properties), detailTiling)); // Two textureless MaterialMaps used purely as transport for a per-material float each: // Bliss uploads every registered map's Value into MaterialBuffer's maps[slot].value @@ -254,45 +381,36 @@ public Material GetOrBuildMaterial(IMaterial material, ushort lightmapIndex = Lo // and its resource-set plumbing. They match no texture layout name, so // DecalAwareForwardRenderer's texture loop skips them. // - // These reproduce the real game's parallax form exactly — height * scale + bias, per the + // These reproduce the real game's parallax form exactly - height * scale + bias, per the // captured shader, where both are per-material fragment constants. Live-tunable per shader // from the ShaderBrowser (see SetParallax) so candidate float pairs spotted in the raw // metadata hex dump can be tried directly against the game's look; the whole point is that // a value read out of the dump can be typed in verbatim, so no hidden scaling factor is // applied on top of these anywhere. - bMat.AddMaterialMap(new MaterialMapKey("fParallaxScale"), 3, new MaterialMap(value: material.ParallaxScale)); - bMat.AddMaterialMap(new MaterialMapKey("fParallaxBias"), 4, new MaterialMap(value: material.ParallaxBias)); + bMat.AddMaterialMap("fParallaxScale", new MaterialMap(value: material.ParallaxScale)); + bMat.AddMaterialMap("fParallaxBias", new MaterialMap(value: material.ParallaxBias)); // Detail map: R,G = derivative perturbation added to the normal map's own derivatives, // B = additive albedo brightness, A = additive specular intensity, the whole fetch gated by // the expensive map's alpha. See IMaterial.DetailTexture. // - // SLOT BUDGET: MaterialData has exactly 8 map slots, and binding the two baked-lighting - // textures below needs two of them. Two were freed rather than routing those textures - // around the MaterialMap system entirely: - // - detailAlbedoStrength is gone. It was pinned to 0 anyway (every non-zero value washes - // surfaces toward white), so a slot carrying a constant zero was pure waste. The - // dataflow is still documented in LitModelShaderSource if it's ever revived. - // - detailSpecStrength moved from its own slot into this map's COLOUR .r channel. Colour - // is byte-quantised 0..1, which is fine for a 0..1 strength at 1/255 granularity, and - // unlike parallax scale/bias it can't legitimately be negative or large. - // detailNormalStrength keeps this map's value slot. + // There are NO per-channel detail strengths: the floats previously read as + // detailNormalStrength/detailSpecStrength/detailAlbedoStrength (ShaderMetadataOld + // 0x28/0x2C/0x30) were misplaced onto what the EBOOT reverse proves is an RGB parameter triple + // (dev/chatgpt-eboot-{4,5}.txt), so they've been removed. The map's value slot carries the + // "this material actually uses its detail map" flag instead; the real per-channel scaling + // constants the game applies are still unsourced. // // Requires BOTH a detail texture and the material's own useDetailMap flag (metadata 0x10, - // see IMaterial.UsesDetailMap) — declaring the map and enabling it are separate things, and + // see IMaterial.UsesDetailMap) - declaring the map and enabling it are separate things, and // a texture reference left in the slot by an unused authoring path shouldn't switch the - // whole detail path on. No detail means zero strengths, so it contributes nothing - // regardless of what is bound; no placeholder detail texture is invented for it, the - // already-existing default model texture just stands in to keep set 7 bound. + // whole detail path on. No placeholder detail texture is invented; the already-existing + // default model texture just stands in to keep the set bound. bool hasDetail = material.DetailTexture != null && material.UsesDetailMap; - var detail = hasDetail ? GetOrBuildTexture(material.DetailTexture!) : GlobalResource.DefaultModelTexture; - float specStrength = hasDetail ? Math.Clamp(material.DetailSpecStrength, 0f, 1f) : 0f; - bMat.AddMaterialMap(new MaterialMapKey("fDetail"), 5, new MaterialMap( - detail, ResolveSampler(detail), - color: new Color((byte)(specStrength * 255f), 0, 0, 255), - value: hasDetail ? material.DetailNormalStrength : 0f)); - - // BAKED LIGHTING (the game's own, from zone sections 0x5400 / 0x5410) — per-INSTANCE, which + var detail = hasDetail ? GetOrBuildTexture(material.DetailTexture!) : GetDefaultAlbedoTexture(); + bMat.AddMaterialMap("fDetail", new MaterialMap(detail, ResolveSampler(detail), hasDetail ? 1f : 0f)); + + // BAKED LIGHTING (the game's own, from zone sections 0x5400 / 0x5410) - per-INSTANCE, which // is why the material cache is keyed on the lightmap index. fLightColour's value slot // doubles as the "this material actually has a bake" flag the shader branches on; without // it the fallback textures below would read as a real full-strength white light. @@ -305,8 +423,15 @@ public Material GetOrBuildMaterial(IMaterial material, ushort lightmapIndex = Lo var lightColour = hasBakedLighting ? ZoneLightmaps[lightmapIndex]! : GetDefaultLightColourTexture(); var lightDir = hasBakedLighting ? ZoneDirectionals[lightmapIndex]! : GetDefaultLightDirTexture(); - bMat.AddMaterialMap(new MaterialMapKey("fLightColour"), 6, new MaterialMap(lightColour, ResolveSampler(lightColour), value: hasBakedLighting ? 1f : 0f)); - bMat.AddMaterialMap(new MaterialMapKey("fLightDir"), 7, new MaterialMap(lightDir, ResolveSampler(lightDir))); + bMat.AddMaterialMap("fLightColour", new MaterialMap(lightColour, ResolveSampler(lightColour), hasBakedLighting ? 1f : 0f)); + bMat.AddMaterialMap("fLightDir", new MaterialMap(lightDir, ResolveSampler(lightDir))); + + // The game's own render mode, plus: 1 when this material has decoded vertex alpha to + // contribute (any non-Opaque mode, see MaterialReader.UsesVertexAlphaCandidate), and whether + // the albedo's own alpha channel is real enough to fold in alongside it (AlbedoHasAlphaChannel) + // rather than being garbage sampled from a format with no alpha channel at all. Neither is a + // texture, so neither belongs in a MaterialMap. + _vkMaterialInfo[bMat] = (material.GameRenderMode, material.UsesVertexAlphaCandidate, material.AlbedoHasAlphaChannel); _materialCache[cacheKey] = bMat; _sourceMaterials[material.Id] = material; @@ -316,129 +441,75 @@ public Material GetOrBuildMaterial(IMaterial material, ushort lightmapIndex = Lo return bMat; } - private Effect SelectEffect(IMaterial material) => _lightingEnabled - ? GetLitModelEffect() - : (material.UsesVertexAlphaCandidate ? GetVertexAlphaModelEffect() : GlobalResource.DefaultModelEffect); - - // Same buffer/texture layout as GlobalResource.DefaultModelEffect (MatrixBuffer@0 vertex, - // TransformBuffer@1 vertex, MaterialBuffer@2 fragment, Albedo texture@3) — a drop-in swap. - // Bliss's bundled default_model shaders never pass vColor through the vertex stage at all - // (confirmed by reading the actual GLSL), so consuming it needs a real second shader rather - // than a material-level trick; kept as our own Effect instead of touching the vendored content - // files so every other material (the overwhelming majority) is completely unaffected. - private Effect GetVertexAlphaModelEffect() => _vertexAlphaModelEffect ??= BuildVertexAlphaModelEffect(); - - private Effect BuildVertexAlphaModelEffect() + /// The material for a foliage sprite card. Deliberately NOT reachable from + /// GetOrBuildMaterial: nothing about a material says "this is a billboard", it is a property of the + /// GEOMETRY (foliage packs a shared anchor into the position and the corner offset into + /// TexCoords2), so routing by shader would silently billboard any mesh that happened to use a + /// foliage shader. EntityFoliage asks for it explicitly. + public RenderMaterial GetOrBuildBillboardMaterial(IMaterial? material) { - var effect = new Effect(_gd, VertexAlphaModelShaderSource.Vertex, VertexAlphaModelShaderSource.Fragment, new CrossCompileOptions(), []); - effect.AddBufferLayout("MatrixBuffer", 0u, SimpleBufferType.Uniform, ShaderStages.Vertex); - effect.AddBufferLayout("TransformBuffer", 1u, SimpleBufferType.Uniform, ShaderStages.Vertex); - effect.AddBufferLayout("MaterialBuffer", 2u, SimpleBufferType.Uniform, ShaderStages.Fragment); - effect.AddTextureLayout(MaterialMapType.Albedo.GetName(), 3u); - return effect; + // Cached per source shader. Every foliage PLACEMENT asks for its material, and a level has + // hundreds of them (757 on metropolis) all sharing a handful of shaders - building a distinct + // Material each time also gave the raw-Vulkan renderer one descriptor set per placement. + if (_billboardMaterialCache.TryGetValue(material?.Id ?? ulong.MaxValue, out var cached)) return cached; + + // Foliage is always double-sided and always blended: both of metropolis's foliage shaders are + // RenderingMode.Blended, and a billboard has no meaningful facing to cull against. The renderer + // gets both facts from the source material's own GameRenderMode below. + var billboard = new RenderMaterial(); + + var albedo = material?.AlbedoTexture != null ? GetOrBuildTexture(material.AlbedoTexture) : GetDefaultAlbedoTexture(); + billboard.AddMaterialMap( + MaterialMapType.Albedo, + new MaterialMap(albedo, ResolveSampler(albedo), material?.AlphaClipThreshold ?? 0f)); + + _vkMaterialInfo[billboard] = (material?.GameRenderMode ?? 0, material?.UsesVertexAlphaCandidate ?? false, material?.AlbedoHasAlphaChannel ?? false); + _billboardMaterials.Add(billboard); + _billboardMaterialCache[material?.Id ?? ulong.MaxValue] = billboard; + return billboard; } - // Same MatrixBuffer@0/TransformBuffer@1/MaterialBuffer@2 base as the other two effects, plus a - // LightBuffer@3 (fragment-stage uniform: direction, ambient, color, camera position — see - // LightData/EditorSettings.LightDirection etc.), then Albedo@4, Normal@5 and a Properties - // texture@6 (specular/parallax-height/emissive intensities — see GetOrBuildMaterial). - // DecalAwareForwardRenderer is what actually binds LightBuffer's resource set (conditionally, - // only for effects that declare it) — see its DrawPreparedRenderable. - // - // ORDERING IS LOAD-BEARING, not stylistic: SimplePipeline builds the pipeline's ResourceLayout - // array as [every buffer layout, in registration order] ++ [every texture layout, in - // registration order], and the Vulkan set index is the POSITION in that array — while - // Effect.GetBufferLayoutSlot/GetTextureLayoutSlot (what DecalAwareForwardRenderer binds - // through) return the slot number declared here. So the two only agree when every buffer takes - // a contiguous slot from 0 and every texture follows immediately after, which is exactly what - // Bliss's own effects do (see GlobalResource: DefaultSkinnedModelEffect registers buffers - // 0-3 then Albedo at 4, and its .frag declares set=3/set=4 to match). - // This previously declared LightBuffer at 5, interleaved after the textures at 3/4. The - // pipeline still laid it out at position 3, so set 3 was a uniform-buffer layout that the - // shader read as texture2D and set 5 was a texture layout that the shader read as a uniform - // buffer. The scalar load of that mangled descriptor is a GPUVM fault - // (CLIENT_ID = SQC (data)) — a hard GPU hang on RADV as soon as lit geometry drew. - // Any new binding added here must keep this invariant, and match LitModelShaderSource's - // "set = N" declarations one-to-one. - private Effect GetLitModelEffect() => _litModelEffect ??= BuildLitModelEffect(); - - private Effect BuildLitModelEffect() - { - var effect = new Effect(_gd, LitModelShaderSource.Vertex, LitModelShaderSource.Fragment, new CrossCompileOptions(), []); - effect.AddBufferLayout("MatrixBuffer", 0u, SimpleBufferType.Uniform, ShaderStages.Vertex); - effect.AddBufferLayout("TransformBuffer", 1u, SimpleBufferType.Uniform, ShaderStages.Vertex); - effect.AddBufferLayout("MaterialBuffer", 2u, SimpleBufferType.Uniform, ShaderStages.Fragment); - effect.AddBufferLayout("LightBuffer", 3u, SimpleBufferType.Uniform, ShaderStages.Fragment); - effect.AddTextureLayout(MaterialMapType.Albedo.GetName(), 4u); - effect.AddTextureLayout(MaterialMapType.Normal.GetName(), 5u); - effect.AddTextureLayout("fProperties", 6u); - effect.AddTextureLayout("fDetail", 7u); - effect.AddTextureLayout("fLightColour", 8u); - effect.AddTextureLayout("fLightDir", 9u); - return effect; - } + /// True if this material was built for foliage sprite cards. The raw-Vulkan renderer needs + /// to know because those cards are billboarded in the VERTEX SHADER from data packed into the + /// geometry, so they need the billboard vertex shader rather than the lit one. + public bool IsBillboardMaterial(RenderMaterial bMat) => _billboardMaterials.Contains(bMat); + + /// Plain white, the stand-in for any albedo-like slot with no texture of its own. Every + /// slot is sampled unconditionally, so "no texture" still has to be something. + private GpuTexture GetDefaultAlbedoTexture() => _defaultAlbedoTexture ??= GpuTexture.Solid(_gd, 255, 255, 255, 255); - private Texture2D GetDefaultNormalTexture() => _defaultNormalTexture ??= - new Texture2D(_gd, new Image(1, 1, new Color(128, 128, 128, 128))); + private GpuTexture GetDefaultNormalTexture() => _defaultNormalTexture ??= GpuTexture.Solid(_gd, 128, 128, 128, 128); // Inert per-channel defaults matching the confirmed expensive-map layout (see // LitModelShaderSource): R=0 no specular, G=0 flat parallax height, B=0 no emissive, - // A=0 no detail mask (so a material with no expensive map pulls in no detail either — A is + // A=0 no detail mask (so a material with no expensive map pulls in no detail either - A is // the detail-map mask, NOT roughness; that reading is retracted). Same "inert" fallback role // GetDefaultNormalTexture plays for Normal. - private Texture2D GetDefaultPropertiesTexture() => _defaultPropertiesTexture ??= - new Texture2D(_gd, new Image(1, 1, new Color(0, 0, 0, 0))); + private GpuTexture GetDefaultPropertiesTexture() => _defaultPropertiesTexture ??= GpuTexture.Solid(_gd, 0, 0, 0, 0); // Bound for materials with no baked lighting, purely so the declared descriptor sets are never // left unbound. Their CONTENTS are irrelevant: the shader gates the whole baked path on // maps[6].value, which is 0 for these materials. White / straight-up are chosen anyway so that // if the flag were ever wrongly set, the result is plainly wrong rather than subtly odd. - private Texture2D GetDefaultLightColourTexture() => _defaultLightColourTexture ??= - new Texture2D(_gd, new Image(1, 1, new Color(255, 255, 255, 255))); + private GpuTexture GetDefaultLightColourTexture() => _defaultLightColourTexture ??= GpuTexture.Solid(_gd, 255, 255, 255, 255); // (128,128,255) decodes through the shader's signed expansion to a tangent-space (0,0,1), // i.e. light coming straight along the surface normal. - private Texture2D GetDefaultLightDirTexture() => _defaultLightDirTexture ??= - new Texture2D(_gd, new Image(1, 1, new Color(128, 128, 255, 255))); + private GpuTexture GetDefaultLightDirTexture() => _defaultLightDirTexture ??= GpuTexture.Solid(_gd, 128, 128, 255, 255); - // No GetDefaultDetailTexture counterpart on purpose — see GetOrBuildMaterial. A material + // No GetDefaultDetailTexture counterpart on purpose - see GetOrBuildMaterial. A material // without a detail map gets zero strengths rather than a fabricated inert texture, so the // "nothing happens" guarantee doesn't depend on getting a placeholder's channel encoding right // (which would matter: 0 is NOT neutral for the signed-expanded R,G derivatives, it decodes to // a full -1, the same trap GetDefaultNormalTexture avoids by using 128). - // Live opt-in toggle, same rationale/pattern as SetBackfaceCulling below — Material.Effect is - // a plain public field, so swapping it on already-cached materials takes effect on the very - // next draw with no rebuild needed. Every material's Vertex3D layout is identical regardless - // of which of these three effects ends up drawing it, so this is safe across the swap. - public void SetLightingEnabled(bool enabled) - { - if (_lightingEnabled == enabled) return; - _lightingEnabled = enabled; - - foreach (var ((shaderId, _), bMat) in _materialCache) - bMat.Effect = SelectEffect(_sourceMaterials[shaderId]); - } - - // Live opt-in toggle, not a rebuild trigger: Bliss's Material.RasterizerState is a plain public - // field read fresh by BasicForwardRenderer.Draw every draw call (verified via IL — it feeds - // directly into that frame's SimplePipelineDescription), so mutating it on the already-cached - // Material instances takes effect on the very next frame with no need to touch geometry or - // rebuild anything. See EditorSettings.BackfaceCulling for why this defaults off. - public void SetBackfaceCulling(bool enabled) - { - if (_backfaceCulling == enabled) return; - _backfaceCulling = enabled; + // Lighting on/off and backface culling used to live here as live Effect / RasterizerState swaps + // on the cached materials. Both are renderer state now: lighting is a uniform the shader branches + // on every frame (VulkanRenderer.Frame's lit argument), and cull mode is baked into the renderer's + // pipelines. Neither has anything left to do with a built material. - var state = enabled ? BackfaceCullState : RasterizerStateDescription.CULL_NONE; - foreach (var material in _materialCache.Values) - material.RasterizerState = state; - } - - // Live scene-wide filtering toggle, same pattern as SetBackfaceCulling/SetLightingEnabled: - // MaterialMap.Sampler is a plain public field read fresh every draw (DecalAwareForwardRenderer - // falls back to PointWrap only when it's null), so mutating the cached maps takes effect next - // frame with no texture/material rebuild. + // Live scene-wide filtering toggle: MaterialMap.Sampler is a plain public field, so mutating the + // cached maps needs no texture or material rebuild. public void SetTextureFiltering(TextureFiltering filtering) { if (_defaultTextureFiltering == filtering) return; @@ -446,7 +517,7 @@ public void SetTextureFiltering(TextureFiltering filtering) RefreshMaterialSamplers(); } - /// Per-texture override (by texture TUID) — the hook for future per-texture + /// Per-texture override (by texture TUID) - the hook for future per-texture /// filtering techniques. Takes effect immediately, wins over the scene-wide default. public void SetTextureFiltering(ulong textureId, TextureFiltering filtering) { @@ -456,14 +527,11 @@ public void SetTextureFiltering(ulong textureId, TextureFiltering filtering) // The three per-channel detail strengths start INERT, not at 1. The game's equivalents are // fragment constants not located in ShaderMetadata yet, so there is no evidence for any value - // — and unlike a multiplicative factor, an additive term has no well-defined "neutral". 1 is + // - and unlike a multiplicative factor, an additive term has no well-defined "neutral". 1 is // actively unsafe here: every one of these contributions is added, and the detail mask driving // them is the expensive map's alpha, which BC1 decodes as 255 on every DXT1 expensive map (see // TextureUtils' Bc1Decoder). At strength 1 that means a full 1.0 lift added to linear albedo, // a full 1.0 added to specular intensity, and a +-1 perturbation added to derivatives already - // in +-1 — the same scene-wide wash the retracted alpha-as-roughness reading produced. Starting - // at 0 keeps these a hunting tool: dial one up on one material and see what the map does. - public const float DefaultDetailStrength = 0f; // Fallback only, for materials whose metadata has no identified tiling (new engine, or a @@ -488,7 +556,7 @@ public void SetParallax(ulong materialId, float scale, float bias) } /// False when the material hasn't been built (nothing in the loaded region uses it) - /// — callers should hide the control rather than show a dead default. + /// - callers should hide the control rather than show a dead default. public bool TryGetParallax(ulong materialId, out float scale, out float bias) { // Every variant of a shader is tuned together, so reading the first is representative. @@ -504,41 +572,28 @@ public bool TryGetParallax(ulong materialId, out float scale, out float bias) return false; } - /// Live per-material detail-map strengths — normal (the R,G derivative perturbation), - /// specular (A) and albedo (B), matching the game's detailNormalStrength / - /// detailSpecStrength / detailAlbedoStrength fragment constants. Same live-tuning rationale as - /// SetParallax: these constants aren't located in the metadata yet. - public void SetDetailStrengths(ulong materialId, float normal, float specular, float tiling) + /// Live per-material detail-map UV tiling (ShaderMetadataOld 0x58 - confirmed by the + /// EBOOT reverse AND by in-game visual comparison). There are no per-channel detail STRENGTHS: the + /// floats once read as those turned out to be an unrelated RGB parameter triple, so only tiling + /// remains tunable here. Rides the fProperties map's value slot - see GetOrBuildMaterial. + public void SetDetailTiling(ulong materialId, float tiling) { if (!_materialsByShader.TryGetValue(materialId, out var variants)) return; - byte spec = (byte)(Math.Clamp(specular, 0f, 1f) * 255f); foreach (var bMat in variants) - { - bMat.SetMapValue(new MaterialMapKey("fDetail"), normal); - // Specular strength rides the detail map's colour .r — see GetOrBuildMaterial for why - // it moved off its own slot. Byte-quantised, hence the 0..1 clamp. - bMat.SetMapColor(new MaterialMapKey("fDetail"), new Color(spec, 0, 0, 255)); - // Rides the fProperties map's value slot — see GetOrBuildMaterial. bMat.SetMapValue(new MaterialMapKey("fProperties"), tiling); - } } /// False when the material hasn't been built (see TryGetParallax) OR has no detail - /// texture at all — in the latter case there is nothing to tune and the strengths are pinned - /// at zero, so callers should hide the controls rather than offer sliders that can only - /// introduce garbage from the placeholder binding. - public bool TryGetDetailStrengths(ulong materialId, out float normal, out float specular, out float tiling) + /// texture at all - in the latter case there is nothing to tune, so callers should hide the + /// control rather than offer a slider against a placeholder binding. + public bool TryGetDetailTiling(ulong materialId, out float tiling) { if (_materialsByShader.TryGetValue(materialId, out var variants) && variants.Count > 0 && _sourceMaterials.TryGetValue(materialId, out var source) && source.DetailTexture != null) { - var bMat = variants[0]; - normal = bMat.GetMapValue(new MaterialMapKey("fDetail")); - specular = (bMat.GetMapColor(new MaterialMapKey("fDetail"))?.R ?? 0) / 255f; - tiling = bMat.GetMapValue(new MaterialMapKey("fProperties")); + tiling = variants[0].GetMapValue(new MaterialMapKey("fProperties")); return true; } - normal = specular = DefaultDetailStrength; tiling = DefaultDetailTiling; return false; } @@ -557,10 +612,10 @@ private void RefreshMaterialSamplers() } // Fallback/default textures (DefaultModelTexture, 1x1 normal/properties) aren't in - // _builtTextureIds and just take the scene default — a per-texture override for a 1x1 + // _builtTextureIds and just take the scene default - a per-texture override for a 1x1 // constant would be meaningless anyway. /// Whether this source format physically carries an alpha channel. Formats without - /// one decode to a synthesised opaque 255, which must not be mistaken for authored data — see + /// one decode to a synthesised opaque 255, which must not be mistaken for authored data - see /// GetOrBuildMaterial's detail-mask handling. DXT3/DXT5 carry explicit alpha; DXT1's 1-bit /// punch-through is a per-block transparency flag, not a mask channel, so it counts as none. /// @@ -575,54 +630,100 @@ private void RefreshMaterialSamplers() _ => false, }; - private Sampler ResolveSampler(Texture2D? texture) => + private Sampler ResolveSampler(GpuTexture? texture) => GetSamplerFor(texture != null && _builtTextureIds.TryGetValue(texture, out var id) && _perTextureFiltering.TryGetValue(id, out var overridden) ? overridden : _defaultTextureFiltering); // The single TextureFiltering -> GPU sampler mapping. Game textures always tile, so every // mode maps to a Wrap-addressing sampler; new filtering techniques are one new enum value - // plus one arm here. + // plus one arm here. Created once each and reused: a Sampler is immutable state, and a level + // asks for one per material map. + private Sampler? _pointWrapSampler; + private Sampler? _linearWrapSampler; + private Sampler GetSamplerFor(TextureFiltering filtering) => filtering switch { - TextureFiltering.Bilinear => GraphicsHelper.GetSampler(_gd, SamplerType.LinearWrap), - _ => GraphicsHelper.GetSampler(_gd, SamplerType.PointWrap), + TextureFiltering.Bilinear => _linearWrapSampler ??= CreateWrapSampler(SamplerFilter.MinLinearMagLinearMipLinear), + _ => _pointWrapSampler ??= CreateWrapSampler(SamplerFilter.MinPointMagPointMipPoint), }; + private Sampler CreateWrapSampler(SamplerFilter filter) => _gd.ResourceFactory.CreateSampler(new SamplerDescription( + SamplerAddressMode.Wrap, SamplerAddressMode.Wrap, SamplerAddressMode.Wrap, + filter, comparisonKind: null, maximumAnisotropy: 0, + // The whole chain. GpuTexture builds one down to 1x1, and clamping the maximum here would + // silently pin minified textures to whichever level the clamp landed on. + minimumLod: 0, maximumLod: uint.MaxValue, lodBias: 0, borderColor: SamplerBorderColor.TransparentBlack)); + /// mipmap: pass false for ATLASES. Mip generation averages neighbouring texels, which - /// on an atlas blends across island boundaries — and the baked lightmap atlases have black + /// on an atlas blends across island boundaries - and the baked lightmap atlases have black /// gutters between their islands, so every minified pixel near an island edge pulls that black /// inward. That shows up as dark patches on lit terrain with no counterpart in the game. /// Normal textures keep mipmaps: they tile, so there are no islands to bleed between. - public Texture2D GetOrBuildTexture(ITexture texture, bool mipmap = true) + public GpuTexture GetOrBuildTexture(ITexture texture, bool mipmap = true) { if (_textureCache.TryGetValue(texture.Id, out var cached)) return cached; _sourceTextures[texture.Id] = texture; - // Some texture slots genuinely have no highmip data for a given level (Texture.ReadTexture - // returns early, leaving data empty, when the highmips pointer's length is 0) — a real, - // already-handled case in the loader, not a corrupt read. TextureUtils.DecodeToRgba8888 - // returns null for that case (and for unrecognized formats) instead of crashing. - byte[]? rgba = TextureUtils.DecodeToRgba8888(texture, out int width, out int height); - if (rgba == null) + // Normally already decoded by PrepareTextures; decoded here only for a texture that pre-pass + // could not see (UFrag and foliage materials are reached during entity loading, after it ran). + // Removed rather than read, so the decoded pixels are freed once uploaded. + if (!_prepared.TryRemove(texture.Id, out var levels)) { - _textureCache[texture.Id] = GlobalResource.DefaultModelTexture; - return GlobalResource.DefaultModelTexture; + // Some texture slots genuinely have no highmip data for a given level (Texture.ReadTexture + // returns early, leaving data empty, when the highmips pointer's length is 0) - a real, + // already-handled case in the loader, not a corrupt read. TextureUtils.DecodeToRgba8888 + // returns null for that case (and for unrecognized formats) instead of crashing. + byte[]? rgba = TextureUtils.DecodeToRgba8888(texture, out int width, out int height); + levels = rgba == null ? null : TextureLevels.Prepare((uint)width, (uint)height, rgba, mipmap); } - var image = new Image(width, height, rgba); - var tex = new Texture2D(_gd, image, mipmap); + if (levels == null) + { + var fallback = GetDefaultAlbedoTexture(); + _textureCache[texture.Id] = fallback; + return fallback; + } + + // Allocated now (cheap: no queue submission, just image+memory) but not uploaded yet - the + // pixel data is queued for UploadOnePendingTexture instead, so a caller loading a whole level + // can spread potentially thousands of GraphicsDevice.UpdateTexture calls across many frames + // instead of blocking through all of them in this one constructor call. Safe to hand out + // immediately: nothing samples it until the scene is actually rendered, well after the queue + // this feeds has had a chance to drain (see View3D's gate on AssetManager.HasPendingUploads). + var tex = new GpuTexture(_gd, levels.Width, levels.Height, (uint)levels.Levels.Length); + _pendingUploads.Enqueue((tex, levels)); + TotalQueuedUploads++; _textureCache[texture.Id] = tex; _builtTextureIds[tex] = texture.Id; return tex; } + // Deferred texture uploads - see GetOrBuildTexture's remarks and UploadOnePendingTexture. + private readonly Queue<(GpuTexture tex, TextureLevels levels)> _pendingUploads = new(); + + /// Total textures ever queued for upload this AssetManager's lifetime, for a progress bar + /// ("done" = this minus ). Never decreases. + public int TotalQueuedUploads { get; private set; } + public int PendingUploadCount => _pendingUploads.Count; + public bool HasPendingUploads => _pendingUploads.Count > 0; + + /// Uploads exactly one queued texture's full mip chain - the same + /// GraphicsDevice.UpdateTexture calls GpuTexture always made, just moved out of the constructor so + /// a caller can call this repeatedly across frames (time-boxed, not all at once) instead of eating + /// the whole level's texture upload cost in a single blocking call. A no-op if nothing is queued. + public void UploadOnePendingTexture() + { + if (!_pendingUploads.TryDequeue(out var item)) return; + item.tex.UploadAll(_gd, item.levels); + } + // Normals and tangents are decoded straight from the source vertex data (VertexFormat0/1's - // packed 11:11:10 words — see PackedNormal/GeometryMath) rather than derived here; GeometryData + // packed 11:11:10 words - see PackedNormal/GeometryMath) rather than derived here; GeometryData // only falls back to UV-gradient derivation for formats that don't carry real data at all. - // useVertexAlpha: see Material.UsesVertexAlphaCandidate — when set, GetVertexAlphaCandidates() + // useVertexAlpha: see Material.UsesVertexAlphaCandidate - when set, GetVertexAlphaCandidates() // is written into each vertex's color alpha instead of the default fully-opaque white, and // GetOrBuildMaterial picks a shader that actually reads it. private static Vertex3D[] ConvertGeometryToVertices(IGeometry geometry, bool useVertexAlpha) @@ -632,6 +733,7 @@ private static Vertex3D[] ConvertGeometryToVertices(IGeometry geometry, bool use var normals = geometry.GetNormals(); var tangents = geometry.GetTangents(); var vertexAlpha = useVertexAlpha ? geometry.GetVertexAlphaCandidates() : null; + var lightmapUVs = geometry.GetLightmapUVs(); int vertexCount = positions.Length / 3; var vertices = new Vertex3D[vertexCount]; @@ -646,26 +748,156 @@ private static Vertex3D[] ConvertGeometryToVertices(IGeometry geometry, bool use ? new Vector4(tangents[i * 4], tangents[i * 4 + 1], tangents[i * 4 + 2], tangents[i * 4 + 3]) : new Vector4(1f, 0f, 0f, 1f); + // Second UV channel is the LIGHTMAP UV set where the geometry has one (ties do; see + // IGeometry.GetLightmapUVs). Falling back to the base UV keeps the attribute + // well-defined for everything else, and is harmless because no lightmap is bound for + // those draws - mirrors EntityUFrag.ConvertUFragToVertices. + var lmUV = lightmapUVs != null && lightmapUVs.Length >= i * 2 + 2 + ? new Vector2(lightmapUVs[i * 2], lightmapUVs[i * 2 + 1]) + : uv; + float alpha = vertexAlpha != null && i < vertexAlpha.Length ? vertexAlpha[i] : 1f; - vertices[i] = new Vertex3D(pos, uv, uv, n, tan, new Vector4(1f, 1f, 1f, alpha)); + vertices[i] = new Vertex3D(pos, uv, lmUV, n, tan, new Vector4(1f, 1f, 1f, alpha)); } return vertices; } - public void Dispose() + // Reused per-frame scratch (moved from View3D's own instance-scoped field): the selected entity's + // world matrices, pushed into the renderer's transform SSBO. Fine to share across whichever single + // 3D view is driving the renderer, same as SceneRenderer itself. + private readonly List _vkTransformScratch = []; + + /// Pushes an edited entity's world matrices straight into the captured scene's transform + /// SSBO, without rebuilding or re-recording anything - see VulkanRenderer.UpdateEntityTransforms. + /// No-op if the scene has not been captured yet. + public void UpdateEntityTransforms(Entity moved) + { + if (SceneRenderer == null) return; + _vkTransformScratch.Clear(); + foreach (var (_, _, world, _) in moved.GetRenderablesForVk()) + _vkTransformScratch.Add(world); + SceneRenderer.UpdateEntityTransforms(moved, _vkTransformScratch, moved.WorldBoundingSphere); + } + + /// Assembles the whole level's scene from the geometry registry (VulkanSceneCapture) and + /// EntityManager's live per-instance world transforms - moved here from View3D verbatim (see that + /// file's history): every dependency below (VulkanSceneCapture, EntityManager.Singleton, this + /// AssetManager itself) was already level-scoped, not panel-scoped, so there was nothing + /// View3D-specific about it in the first place. Only geometries actually referenced by an instance + /// are included, remapped to a compact index. Returns null until instances exist. + private (List verts, List idx, List materials, + List<(int geo, int mat, Matrix4x4 world, Vector4 sphere, object owner, float displayDistance, uint pickId)> instances, + Texture? envCube)? BuildVkScene() { - foreach (var models in Mobys.Values) - foreach (var model in models) - model.Dispose(); + if (VulkanSceneCapture.VertexData.Count == 0) + return null; - foreach (var model in Ties.Values) - model.Dispose(); + var verts = new List(); + var idx = new List(); + var materials = new List(); + var instances = new List<(int, int, Matrix4x4, Vector4, object, float, uint)>(); + var geoRemap = new Dictionary(); + var matRemap = new Dictionary(ReferenceEqualityComparer.Instance); + foreach (var entity in EntityManager.Singleton.AllEntities()) + { + foreach (var (mesh, material, world, sphere) in entity.GetRenderablesForVk()) + { + if (!VulkanSceneCapture.TryGet(mesh, out int gi)) + continue; + if (!geoRemap.TryGetValue(gi, out int geoSlot)) + { + geoSlot = verts.Count; + verts.Add(VulkanSceneCapture.VertexData[gi]); + idx.Add(VulkanSceneCapture.Indices[gi]); + geoRemap[gi] = geoSlot; + } + if (!matRemap.TryGetValue(material, out int matSlot)) + { + matSlot = materials.Count; + materials.Add(VkMaterialBuilder.Build(material, this)); + matRemap[material] = matSlot; + } + float displayDistance = entity is EntityMoby moby ? moby.DisplayDistance : -1f; + instances.Add((geoSlot, matSlot, world, sphere, entity, displayDistance, (uint)entity.ID)); + } + } + + if (instances.Count == 0) + return null; + var envCube = EnvironmentCubemapView?.Target; + return (verts, idx, materials, instances, envCube); + } + + /// Builds if it does not exist yet - a no-op once it does, which + /// is the whole point: the caller (View3D) can call this every frame with no cost once the scene is + /// captured, instead of needing to track "have I captured yet" itself. Also a no-op while textures + /// are still uploading (), so the very first capture never samples a + /// texture before its pixel data has actually reached the GPU. Returns true once SceneRenderer is + /// ready to use (whether captured just now or already captured before). + public bool TryCaptureScene(GraphicsDevice gd, uint viewWidth, uint viewHeight) + { + if (SceneRenderer != null) return true; + if (HasPendingUploads) return false; + + try + { + var scene = BuildVkScene(); + if (scene is not { } s) return false; + SceneRenderer = new VulkanRenderer(gd, s.verts, s.idx, s.materials, s.instances, s.envCube, viewWidth, viewHeight); + return true; + } + catch (Exception e) + { + Console.WriteLine($"[VkRenderer] init failed: {e.Message}"); + SceneRenderer = null; + return false; + } + } + + /// Disposes and drops after it faults mid-frame (submit/resize + /// failure) - the caller just tried to use it and hit an exception, so the safe recovery is to + /// throw the whole thing away and let TryCaptureScene rebuild it next frame, same as before this + /// was ever captured. Unlike the ad-hoc "set the field to null" this replaces, this actually + /// disposes the GPU resources first instead of leaking them on the failure path. + public void InvalidateSceneRenderer() + { + SceneRenderer?.Dispose(); + SceneRenderer = null; + } + + /// Tears down the captured scene - called explicitly by LunaWindow.TryWipeLevel, BEFORE + /// EntityManager.Singleton.Dispose(): the scene references live entity meshes/geometry and the + /// VulkanSceneCapture registry, both of which the level's own disposal invalidates. This is + /// deliberately not part of Dispose() itself, which callers only reach afterwards (Dispose() then + /// frees the textures/materials SceneRenderer's descriptor sets point at, which is only safe once + /// SceneRenderer itself is already gone). + public void DisposeSceneRenderer() + { + SceneRenderer?.Dispose(); + SceneRenderer = null; + VulkanSceneCapture.Clear(); + } + + public void Dispose() + { + // Safety net: the real teardown order is DisposeSceneRenderer() then this (see that method's + // remarks) - SceneRenderer's descriptor sets reference the textures freed below, so it must + // already be gone before they go. Idempotent (DisposeSceneRenderer already nulls it) - only + // does anything if some future caller reaches Dispose() without calling that first. + SceneRenderer?.Dispose(); + SceneRenderer = null; + + // Models and meshes hold no GPU resources any more, so only the textures need releasing. + // The shared default stands in for every texture that failed to decode, so it is in the cache + // many times over and must not be disposed through it. foreach (var texture in _textureCache.Values) - if (texture != GlobalResource.DefaultModelTexture) + if (texture != _defaultAlbedoTexture) texture.Dispose(); + _defaultAlbedoTexture?.Dispose(); + _defaultAlbedoTexture = null; _defaultNormalTexture?.Dispose(); _defaultNormalTexture = null; _defaultPropertiesTexture?.Dispose(); @@ -675,10 +907,15 @@ public void Dispose() _defaultLightDirTexture?.Dispose(); _defaultLightDirTexture = null; - _vertexAlphaModelEffect?.Dispose(); - _vertexAlphaModelEffect = null; - _litModelEffect?.Dispose(); - _litModelEffect = null; + _pointWrapSampler?.Dispose(); + _pointWrapSampler = null; + _linearWrapSampler?.Dispose(); + _linearWrapSampler = null; + + EnvironmentCubemapView?.Dispose(); + EnvironmentCubemapView = null; + _environmentCubemap?.Dispose(); + _environmentCubemap = null; Mobys.Clear(); Ties.Clear(); diff --git a/ReLunacy.Engine/Rendering/DecalAwareForwardRenderer.cs b/ReLunacy.Engine/Rendering/DecalAwareForwardRenderer.cs deleted file mode 100644 index f664d08..0000000 --- a/ReLunacy.Engine/Rendering/DecalAwareForwardRenderer.cs +++ /dev/null @@ -1,256 +0,0 @@ -using System.Numerics; -using Bliss.CSharp.Camera.Dim3; -using Bliss.CSharp.Graphics; -using Bliss.CSharp.Graphics.Pipelines; -using Bliss.CSharp.Graphics.Pipelines.Buffers; -using Bliss.CSharp.Graphics.Pipelines.Textures; -using Bliss.CSharp.Graphics.Rendering; -using Bliss.CSharp.Graphics.Rendering.Renderers.Forward; -using Bliss.CSharp.Materials; -using Veldrith; - -namespace ReLunacy.Engine.Rendering; - -/// -/// Drop-in replacement for Bliss's BasicForwardRenderer that disables depth *writes* (but keeps -/// depth testing) for translucent renderables. BasicForwardRenderer hardcodes -/// DepthStencilStateDescription.DEPTH_ONLY_LESS_EQUAL (depth write ON) for every renderable -/// regardless of RenderMode, and never exposes a way to change that per-material/per-draw. -/// That's fine for opaque geometry, but any AlphaBlend "decal" mesh that sits flush against -/// (or very close to) the opaque surface it's meant to fade into — e.g. UFrag vine/moss decals -/// painted directly onto terrain chunks — z-fights against that surface once it writes its own -/// depth, since both surfaces occupy nearly the same depth value. The result: instead of a soft -/// alpha fade, large parts of the decal randomly fail the depth test and are never drawn at all, -/// which reads as a hard rectangular clip rather than a gradient. Verified against Bliss 1.6.15's -/// real source (BasicForwardRenderer.cs on GitHub, matches the shipped DLL) — this isn't -/// configurable there, and BasicForwardRenderer's methods aren't virtual, so it can't be patched -/// via inheritance; hence this parallel implementation via the small IRenderer interface. -/// -public class DecalAwareForwardRenderer : IRenderer -{ - private readonly List _opaqueRenderables = []; - private readonly List _translucentRenderables = []; - private SimplePipelineDescription _pipelineDescription; - - // Scene-wide, not per-renderable — only ever bound for materials whose Effect declares a - // "LightBuffer" layout (currently just LitModelShaderSource, via AssetManager's lit effect), - // checked by name in DrawPreparedRenderable rather than assumed, since GetBufferLayoutSlot - // throws KeyNotFoundException for any effect that doesn't declare it (every other effect in - // this engine, at least for now). Values default to a plain downward light — View3D pushes - // the real EditorSettings.LightDirection/LightColor/Ambient in every frame, same pattern as - // Camera.FarPlane/VolumeWireThickness. - private readonly SimpleUniformBuffer _lightBuffer; - public Vector3 LightDirection = new(-0.4f, -0.8f, 0.3f); - public Vector3 LightColor = Vector3.One; - public float Ambient = 0.15f; - public float SpecularPower = 32f; - // Averaged from the level's own cubemap at load; see LightData.EnvironmentColour. Intensity - // defaults to 0 so nothing changes until a level actually supplies one. - public Vector3 EnvironmentColour = Vector3.One; - public float EnvironmentIntensity; - // Live lightmap research controls — see LightData for what each one stands in for. - public Vector2 LightmapUVScale = Vector2.One; - public Vector2 LightmapUVOffset = Vector2.Zero; - public float BakedLightScale = 4f; - public float BakedBumpFade = 1f; - public bool BakedDebugView; - public Vector2 LightmapUVPivot = new(0.5f, 0.5f); - public float LightmapUVRotation; - - public GraphicsDevice GraphicsDevice { get; } - - public DecalAwareForwardRenderer(GraphicsDevice graphicsDevice) - { - GraphicsDevice = graphicsDevice; - _pipelineDescription = new SimplePipelineDescription - { - PrimitiveTopology = PrimitiveTopology.TriangleList - }; - _lightBuffer = new SimpleUniformBuffer(graphicsDevice, 1u, ShaderStages.Fragment); - } - - public void DrawRenderable(Renderable renderable) - { - if (renderable.Material.RenderMode == RenderMode.Translucent) - _translucentRenderables.Add(renderable); - else - _opaqueRenderables.Add(renderable); - } - - public void Draw(CommandList commandList, OutputDescription output) - { - var cam3D = Cam3D.ActiveCamera; - if (cam3D == null) - return; - - _opaqueRenderables.Sort((a, b) => Vector3.DistanceSquared(a.GetTransforms()[0].Translation, cam3D.Position).CompareTo(Vector3.DistanceSquared(b.GetTransforms()[0].Translation, cam3D.Position))); - _translucentRenderables.Sort((a, b) => Vector3.DistanceSquared(b.GetTransforms()[0].Translation, cam3D.Position).CompareTo(Vector3.DistanceSquared(a.GetTransforms()[0].Translation, cam3D.Position))); - - _pipelineDescription.Outputs = output; - - // Scene-wide, so this only needs updating once per frame rather than per-renderable like - // UpdateRenderableBuffer below — direction is normalized here rather than trusting the - // caller, since EditorSettings.LightDirection is a freely-edited ImGui field with no - // guarantee of unit length. Not the light-direction convention: a since-reverted attempt - // at negating it here didn't fix the "inverted everywhere" symptom, which pointed back at - // normal-map reconstruction instead (see LitModelShaderSource). - var lightData = new LightData - { - Direction = LightDirection.LengthSquared() > 0f ? Vector3.Normalize(LightDirection) : Vector3.UnitY, - Ambient = Ambient, - Color = LightColor, - // Clamped away from 0: pow(x, 0) is 1 everywhere, which would paint the entire scene - // with a full-strength "highlight" if the setting were dragged to zero. - SpecularPower = MathF.Max(SpecularPower, 1f), - CameraPosition = cam3D.Position, - EnvironmentColour = EnvironmentColour, - EnvironmentIntensity = EnvironmentIntensity, - LightmapUVScale = LightmapUVScale, - LightmapUVOffset = LightmapUVOffset, - BakedLightScale = BakedLightScale, - BakedBumpFade = BakedBumpFade, - BakedDebugView = BakedDebugView ? 1f : 0f, - LightmapUVPivot = LightmapUVPivot, - LightmapUVRotation = LightmapUVRotation, - }; - _lightBuffer.SetValueDeferred(commandList, 0, ref lightData); - - // Bliss's Material.IsDirty is cleared by the FIRST renderable that uploads it - // (Renderable.UpdateMaterialBuffer sets Material.IsDirty = false), so with this engine's - // shared cached materials (one Material instance across every mesh using that shader), a - // live material edit - e.g. AssetManager.SetParallax - would only ever reach one - // renderable per frame through the flag alone. Snapshot which materials are dirty BEFORE - // any upload clears the flag, and force the update for every renderable sharing them. - _dirtyMaterials.Clear(); - foreach (var renderable in _opaqueRenderables) - if (renderable.Material.IsDirty) - _dirtyMaterials.Add(renderable.Material); - foreach (var renderable in _translucentRenderables) - if (renderable.Material.IsDirty) - _dirtyMaterials.Add(renderable.Material); - - foreach (var renderable in _opaqueRenderables) - UpdateRenderableBuffer(commandList, renderable, _dirtyMaterials); - foreach (var renderable in _translucentRenderables) - UpdateRenderableBuffer(commandList, renderable, _dirtyMaterials); - - _pipelineDescription.DepthStencilState = DepthStencilStateDescription.DEPTH_ONLY_LESS_EQUAL; - foreach (var renderable in _opaqueRenderables) - DrawPreparedRenderable(commandList, cam3D, renderable); - - // No depth WRITE for translucent/decal geometry — depth TEST still applies (so decals - // still occlude correctly behind opaque geometry in front of them), it just stops - // polluting the depth buffer against the near-coplanar surface it's blending onto. - _pipelineDescription.DepthStencilState = DepthStencilStateDescription.DEPTH_ONLY_LESS_EQUAL_READ; - foreach (var renderable in _translucentRenderables) - DrawPreparedRenderable(commandList, cam3D, renderable); - - _opaqueRenderables.Clear(); - _translucentRenderables.Clear(); - } - - private void DrawPreparedRenderable(CommandList commandList, Cam3D camera, Renderable renderable) - { - _pipelineDescription.BlendState = renderable.Material.BlendState; - _pipelineDescription.RasterizerState = renderable.Material.RasterizerState; - _pipelineDescription.BufferLayouts = renderable.Material.Effect.GetBufferLayouts(); - _pipelineDescription.TextureLayouts = renderable.Material.Effect.GetTextureLayouts(); - _pipelineDescription.ShaderSet = new ShaderSetDescription(renderable.Mesh.VertexFormat.Layouts, renderable.Mesh.Material.Effect.Shaders); - - commandList.SetPipeline(renderable.Material.Effect.GetPipeline(_pipelineDescription).Pipeline); - - commandList.SetGraphicsResourceSet(renderable.Material.Effect.GetBufferLayoutSlot("MatrixBuffer"), camera.GetMatrixBuffer().GetResourceSet(renderable.Material.Effect.GetBufferLayout("MatrixBuffer"))); - commandList.SetGraphicsResourceSet(renderable.Material.Effect.GetBufferLayoutSlot("TransformBuffer"), renderable.GetTransformBuffer().GetResourceSet(renderable.Material.Effect.GetBufferLayout("TransformBuffer"))); - - if (renderable.HasBones) - { - var boneBuffer = renderable.GetBoneBuffer(); - if (boneBuffer != null) - commandList.SetGraphicsResourceSet(renderable.Material.Effect.GetBufferLayoutSlot("BoneBuffer"), boneBuffer.GetResourceSet(renderable.Material.Effect.GetBufferLayout("BoneBuffer"))); - } - - commandList.SetGraphicsResourceSet(renderable.Material.Effect.GetBufferLayoutSlot("MaterialBuffer"), renderable.GetMaterialBuffer().GetResourceSet(renderable.Material.Effect.GetBufferLayout("MaterialBuffer"))); - - // Only effects that actually declare LightBuffer (currently just AssetManager's lit - // effect) get it bound — GetBufferLayoutSlot/GetBufferLayout throw KeyNotFoundException - // for a name the effect never registered, so this can't be called unconditionally the - // way MatrixBuffer/TransformBuffer/MaterialBuffer are above. - foreach (var bufferLayout in renderable.Material.Effect.GetBufferLayouts()) - { - if (bufferLayout.Name != "LightBuffer") - continue; - commandList.SetGraphicsResourceSet(renderable.Material.Effect.GetBufferLayoutSlot("LightBuffer"), _lightBuffer.GetResourceSet(bufferLayout)); - break; - } - - foreach (SimpleTextureLayout textureLayout in renderable.Material.Effect.GetTextureLayouts()) - { - foreach (var materialMapKey in renderable.Material.GetMaterialMapKeys()) - { - if (textureLayout.Name != materialMapKey.Name) - continue; - - string name = textureLayout.Name; - var materialMap = renderable.Material.GetMaterialMap(materialMapKey); - var textureResourceSet = materialMap!.GetTextureResourceSet(materialMap.Sampler ?? GraphicsHelper.GetSampler(GraphicsDevice, SamplerType.PointWrap), renderable.Material.Effect.GetTextureLayout(name)); - if (textureResourceSet != null) - commandList.SetGraphicsResourceSet(renderable.Material.Effect.GetTextureLayoutSlot(name), textureResourceSet); - } - } - - renderable.Material.Effect.Apply(commandList, renderable.Material); - - if (renderable.Mesh.IndexCount != 0) - { - commandList.SetVertexBuffer(0, renderable.Mesh.VertexBuffer); - commandList.SetIndexBuffer(renderable.Mesh.IndexBuffer, IndexFormat.UInt32); - - if (renderable.UseInstancing) - { - commandList.SetVertexBuffer(1, renderable.GetInstanceVertexBuffer()); - commandList.DrawIndexed(renderable.Mesh.IndexCount, renderable.InstanceCount, 0, 0, 0); - } - else - { - commandList.DrawIndexed(renderable.Mesh.IndexCount); - } - } - else - { - commandList.SetVertexBuffer(0, renderable.Mesh.VertexBuffer); - - if (renderable.UseInstancing) - { - commandList.SetVertexBuffer(1, renderable.GetInstanceVertexBuffer()); - commandList.Draw(renderable.Mesh.VertexCount, renderable.InstanceCount, 0, 0); - } - else - { - commandList.Draw(renderable.Mesh.VertexCount); - } - } - } - - // Reused across frames to avoid a per-frame allocation; only ever touched inside Draw. - private readonly HashSet _dirtyMaterials = []; - - private static void UpdateRenderableBuffer(CommandList commandList, Renderable renderable, HashSet dirtyMaterials) - { - if (renderable.IsTransformBufferDirty) - renderable.UpdateTransformBuffer(commandList); - if (renderable.IsInstanceVertexBufferDirty) - renderable.UpdateInstanceVertexBuffer(commandList); - if (renderable.IsBoneBufferDirty) - renderable.UpdateBoneBuffer(commandList); - // dirtyMaterials: see Draw - IsMaterialBufferDirty alone misses shared-material - // renderables once the first upload clears Material.IsDirty. - if (renderable.IsMaterialBufferDirty || dirtyMaterials.Contains(renderable.Material)) - renderable.UpdateMaterialBuffer(commandList); - } - - public void Dispose() - { - _lightBuffer.Dispose(); - GC.SuppressFinalize(this); - } -} diff --git a/ReLunacy.Engine/Rendering/EditorCamera.cs b/ReLunacy.Engine/Rendering/EditorCamera.cs new file mode 100644 index 0000000..0d9a654 --- /dev/null +++ b/ReLunacy.Engine/Rendering/EditorCamera.cs @@ -0,0 +1,119 @@ +using System.Numerics; + +namespace ReLunacy.Engine.Rendering; + +/// A look-at camera: position, target, up, and a perspective projection. +/// +/// Replaces Bliss's Cam3D, whose behaviour this reproduces exactly for the way the editor drives it +/// (its own Custom mode - the editor moves the camera itself and never used Cam3D's built-in movement +/// modes). Two differences are deliberate: +/// +/// - rebuilds both matrices. Cam3D only rebuilt them inside Begin(), which was a +/// command-list call, so a view with no command list would silently keep serving the matrices from +/// whenever it last drew. Nothing here touches the graphics API at all. +/// - / are read straight off the forward vector instead of +/// round-tripping the view matrix through a quaternion and Euler angles. The callers only ever use +/// them as Set(Get() - delta), where the absolute value cancels out and only the delta survives, so +/// this is observably identical while being far better conditioned. +public sealed class EditorCamera +{ + public Vector3 Position; + public Vector3 Target; + public Vector3 Up; + + /// Vertical field of view, in DEGREES (Cam3D's convention, and what the settings store). + public float Fov; + public float NearPlane; + public float FarPlane; + + public float AspectRatio { get; private set; } = 1f; + + private Matrix4x4 _view = Matrix4x4.Identity; + private Matrix4x4 _projection = Matrix4x4.Identity; + + public EditorCamera(Vector3 position, Vector3 target, Vector3 up, float fov, float nearPlane, float farPlane) + { + Position = position; + Target = target; + Up = up; + Fov = fov; + NearPlane = nearPlane; + FarPlane = farPlane; + Update(); + } + + /// Right-handed look-at, and a right-handed perspective with +Y up - the same + /// System.Numerics calls Cam3D made, so every downstream convention is unchanged (including the + /// renderer's negative-height viewport, which is what maps +Y-up clip space onto Vulkan). + public void Update() + { + _projection = Matrix4x4.CreatePerspectiveFieldOfView( + float.DegreesToRadians(Fov), AspectRatio, NearPlane, FarPlane); + _view = Matrix4x4.CreateLookAt(Position, Target, Up); + } + + public void Resize(uint width, uint height) + { + if (width == 0 || height == 0) return; + AspectRatio = width / (float)height; + } + + public Matrix4x4 GetView() => _view; + public Matrix4x4 GetProjection() => _projection; + + public Vector3 GetForward() => Vector3.Normalize(Target - Position); + + /// Deliberately NOT normalized, matching Cam3D: callers that need a unit vector normalize + /// it themselves, and the pitch rotation only uses it as an axis (which gets normalized anyway). + public Vector3 GetRight() => Vector3.Cross(GetForward(), Up); + + public float GetYaw() => float.RadiansToDegrees(MathF.Atan2(GetForward().X, GetForward().Z)); + + public float GetPitch() => float.RadiansToDegrees(MathF.Asin(Math.Clamp(GetForward().Y, -1f, 1f))); + + /// Rotates about by (angle - current yaw). + /// orbits the position about the target; otherwise the target swings about the position. + public void SetYaw(float angle, bool rotateAroundTarget) + { + float delta = float.DegreesToRadians(angle - GetYaw()); + Vector3 rotated = RotateByAxisAngle(Target - Position, Up, delta); + if (rotateAroundTarget) Position = Target - rotated; + else Target = Position + rotated; + } + + /// Rotates about by (angle - current pitch), clamped so the view + /// direction can never reach or cross either pole - that clamp is what stops the camera flipping + /// upside down, so it is reproduced exactly (including the 0.001 rad guard band). + public void SetPitch(float angle, bool rotateAroundTarget) + { + float delta = float.DegreesToRadians(angle - GetPitch()); + Vector3 toTarget = Target - Position; + + float maxUp = AngleBetween(Up, toTarget) - 0.001f; + float maxDown = -AngleBetween(-Up, toTarget) + 0.001f; + delta = Math.Clamp(delta, maxDown, maxUp); + + Vector3 rotated = RotateByAxisAngle(toTarget, GetRight(), delta); + if (rotateAroundTarget) Position = Target - rotated; + else Target = Position + rotated; + } + + /// Dollies along the view direction. Positive pulls away from the + /// target; the distance is floored just above zero so the look-at never degenerates. + public void MoveToTarget(float delta) + { + float distance = Vector3.Distance(Position, Target) + delta; + if (!(distance > 0f)) distance = 0.001f; + Position = Target + GetForward() * -distance; + } + + private static Vector3 RotateByAxisAngle(Vector3 v, Vector3 axis, float angle) => + Vector3.Transform(v, Quaternion.CreateFromAxisAngle(Vector3.Normalize(axis), angle)); + + private static float AngleBetween(Vector3 a, Vector3 b) + { + float denominator = a.Length() * b.Length(); + if (denominator <= 0f) return 0f; + return MathF.Acos(Math.Clamp(Vector3.Dot(a, b) / denominator, -1f, 1f)); + } +} diff --git a/ReLunacy.Engine/Rendering/LightData.cs b/ReLunacy.Engine/Rendering/LightData.cs index cdd9b65..257a494 100644 --- a/ReLunacy.Engine/Rendering/LightData.cs +++ b/ReLunacy.Engine/Rendering/LightData.cs @@ -3,16 +3,16 @@ namespace ReLunacy.Engine.Rendering; -/// GPU-side layout for LightModelShaderSource's LightBuffer uniform — must match its +/// GPU-side layout for LightModelShaderSource's LightBuffer uniform - must match its /// std140 layout exactly. Vector3 (12 bytes) followed by a scalar float packs into a 16-byte slot /// under std140's own alignment rules (a vec3's base alignment is 16 bytes, and a directly /// following 4-byte scalar fills the leftover space), which is also exactly how this sequential -/// C# struct lays out — Direction+Ambient, Color+padding and CameraPosition+padding each occupy +/// C# struct lays out - Direction+Ambient, Color+padding and CameraPosition+padding each occupy /// one 16-byte block, 48 bytes total. CameraPosition isn't really "light" data, but it's needed /// for specular's view-direction term and this is already the one scene-wide per-frame buffer -/// every lit material binds — see DecalAwareForwardRenderer.Draw. SpecularPower is scene-wide +/// every lit material binds - see DecalAwareForwardRenderer.Draw. SpecularPower is scene-wide /// (not per-material) because this game's texture format has no per-pixel specular-power channel -/// to sample — see LitModelShaderSource's header comment. +/// to sample - see LitModelShaderSource's header comment. [StructLayout(LayoutKind.Sequential)] public struct LightData { @@ -21,7 +21,10 @@ public struct LightData public Vector3 Color; public float SpecularPower; public Vector3 CameraPosition; - private readonly float _padding; + /// >0.5 renders the raw cubemap reflection (envColour) on every surface, ungated by + /// specIntensity/EnvironmentIntensity, so the near-invisible reflection can be seen and its axis + /// orientation checked. Occupies uCameraPosition's std140 tail slot (was a reserved pad). + public float ReflectionDebugView; /// Flat stand-in for the environment cubemap, averaged from the level's own (see /// TextureShaderLoader.EnvironmentAverage). The game's specular term is additive and /// independent of the lightmap, so it is what keeps baked shadows from reaching pure black; @@ -31,7 +34,7 @@ public struct LightData public float EnvironmentIntensity; /// Live lightmap UV transform: uv = fTexCoords2 * Scale + Offset. A RESEARCH CONTROL, - /// not a game value — the game needs no such transform because UFragVertex.UVs2 are already + /// not a game value - the game needs no such transform because UFragVertex.UVs2 are already /// atlas coordinates. It exists so a suspected missing scalar/offset can be searched for by eye /// against the real game. Identity is Scale=(1,1), Offset=(0,0). /// Same 16-byte std140 block rule as the vec3+float pairs above (vec2+vec2 = one block). @@ -47,7 +50,11 @@ public struct LightData /// >0.5 renders the raw baked light colour instead of shading, so "why is this /// black" splits into bake-is-black vs shading-kills-it at a glance. public float BakedDebugView; - private readonly float _padding2; + /// Head-on reflectance (Fresnel F0) for the cubemap reflection: 0 = reflect only where + /// the material's specular map says to (the faithful default), rising to 1 = near-mirror on every + /// surface. Grazing angles always reflect fully regardless. See LitModelShaderSource's ENVIRONMENT + /// FILL. Occupies the std140 slot after uBakedDebugView (was a reserved pad). + public float ReflectionBase; /// Pivot the lightmap UV rotation turns about, in UV space. Adjustable rather than /// fixed at the atlas centre on purpose: these are ATLAS coordinates, so rotating about (0.5, @@ -56,7 +63,33 @@ public struct LightData /// whether a bake is stored rotated. public Vector2 LightmapUVPivot; /// Lightmap UV rotation in DEGREES, about LightmapUVPivot. Applied before scale and - /// offset. Research control — the game has no such rotation. + /// offset. Research control - the game has no such rotation. public float LightmapUVRotation; - private readonly float _padding3; + /// How much of the undecoded-surface fill (the level's analytic ambient, or the flat + /// editor ambient) is added UNDER a baked surface, 0..1. + /// + /// A bake stores total incident irradiance plus a dominant direction, so reconstructing it with a + /// hard clamped N.L is wrong at the bottom end: wherever the parallax-perturbed normal tilts past + /// the baked light direction, N.L hits exactly 0 and the surface goes pure black, with nothing to + /// catch it. The real engine has the cubemap reflection sitting under it, but that term is gated on + /// specular intensity, so it vanishes on matte surfaces and the black comes back. + /// + /// Research control: the game has no such constant. It exists to put a floor under the bake. + public float BakedAmbient; + + // The level's analytic lighting environment (main.dat section 0x8b00), the game's own sun/ambient + // - see Assets.Lighting.LightingEnvironment and the shader's undecoded-surface lighting. Two + // directional lights (Direction0/1 + Colour1/2) plus an ambient (Colour0). EnvHasLighting is >0.5 + // only when the level actually supplied one; otherwise the shader falls back to the flat editor + // ambient. Each Vector3+float pair is one std140 16-byte block, same rule as the pairs above. + public Vector3 EnvDirection0; + public float EnvHasLighting; + public Vector3 EnvDirection1; + private readonly float _padding4; + public Vector3 EnvAmbient; // Colour0 + private readonly float _padding5; + public Vector3 EnvLight0Colour; // Colour1 + private readonly float _padding6; + public Vector3 EnvLight1Colour; // Colour2 + private readonly float _padding7; } diff --git a/ReLunacy.Engine/Rendering/PickingRenderer.cs b/ReLunacy.Engine/Rendering/PickingRenderer.cs deleted file mode 100644 index 4b4664d..0000000 --- a/ReLunacy.Engine/Rendering/PickingRenderer.cs +++ /dev/null @@ -1,269 +0,0 @@ -using System.Numerics; -using System.Runtime.InteropServices; -using System.Text; -using Bliss.CSharp.Geometry.Meshes; -using Veldrith; -using Veldrith.SPIRV; - -namespace ReLunacy.Engine.Rendering; - -// Off-screen GPU object-ID picking: renders every pickable mesh into a dedicated color+depth -// target with the fragment shader outputting the entity's ID packed as RGBA8, then reads back a -// small window of pixels around the cursor and takes the nearest non-background hit. Replaces -// CPU ray/bounding-sphere picking so overlapping/occluded geometry resolves correctly. Bangles/ -// submeshes of a Moby all draw with the same entity ID (the caller passes one ID per entity, not -// per mesh) so only whole entities are selectable in the 3D view. -public sealed class PickingRenderer : IDisposable -{ - private const string VertSource = """ - #version 450 - - layout(std140, set = 0, binding = 0) uniform PickingBuffer { - mat4 uViewProjection; - mat4 uWorld; - uint uObjectId; - }; - - layout(location = 0) in vec3 vPosition; - layout(location = 1) in vec2 vTexCoords; - layout(location = 2) in vec2 vTexCoords2; - layout(location = 3) in vec3 vNormal; - layout(location = 4) in vec4 vTangent; - layout(location = 5) in vec4 vColor; - - void main() { - gl_Position = uViewProjection * uWorld * vec4(vPosition, 1.0); - } - """; - - private const string FragSource = """ - #version 450 - - layout(std140, set = 0, binding = 0) uniform PickingBuffer { - mat4 uViewProjection; - mat4 uWorld; - uint uObjectId; - }; - - layout(location = 0) out vec4 fFragColor; - - void main() { - fFragColor = vec4( - float( uObjectId & 0xFFu) / 255.0, - float((uObjectId >> 8) & 0xFFu) / 255.0, - float((uObjectId >> 16) & 0xFFu) / 255.0, - float((uObjectId >> 24) & 0xFFu) / 255.0 - ); - } - """; - - private readonly GraphicsDevice _gd; - private readonly Shader[] _shaders; - private readonly ResourceLayout _layout; - private readonly Pipeline _pipeline; - private readonly DeviceBuffer _uniformBuffer; - private readonly ResourceSet _resourceSet; - private readonly CommandList _commandList; - - // Clicks near a silhouette/thin object can land on a background pixel by a pixel or two — - // rather than trusting the exact pixel under the cursor, read back a small window around it - // and pick the nearest non-background hit. Fixed size, independent of viewport resizing. - private const int PickWindowSize = 4; - - private Texture _colorTexture = null!; - private Texture _depthTexture = null!; - private Framebuffer _framebuffer = null!; - private readonly Texture _stagingTexture; - private uint _width = 1, _height = 1; - - public PickingRenderer(GraphicsDevice gd) - { - _gd = gd; - var factory = gd.ResourceFactory; - - _shaders = factory.CreateFromSpirv( - new ShaderDescription(ShaderStages.Vertex, Encoding.UTF8.GetBytes(VertSource), "main"), - new ShaderDescription(ShaderStages.Fragment, Encoding.UTF8.GetBytes(FragSource), "main")); - - var vertexLayout = new VertexLayoutDescription( - new VertexElementDescription("vPosition", VertexElementSemantic.TextureCoordinate, VertexElementFormat.Float3), - new VertexElementDescription("vTexCoords", VertexElementSemantic.TextureCoordinate, VertexElementFormat.Float2), - new VertexElementDescription("vTexCoords2", VertexElementSemantic.TextureCoordinate, VertexElementFormat.Float2), - new VertexElementDescription("vNormal", VertexElementSemantic.TextureCoordinate, VertexElementFormat.Float3), - new VertexElementDescription("vTangent", VertexElementSemantic.TextureCoordinate, VertexElementFormat.Float4), - new VertexElementDescription("vColor", VertexElementSemantic.TextureCoordinate, VertexElementFormat.Float4)); - - _layout = factory.CreateResourceLayout(new ResourceLayoutDescription( - new ResourceLayoutElementDescription("PickingBuffer", ResourceKind.UniformBuffer, ShaderStages.Vertex | ShaderStages.Fragment))); - - // 2 * mat4 (64 bytes each) + 1 uint, rounded up to a 16-byte-aligned uniform buffer size. - _uniformBuffer = factory.CreateBuffer(new BufferDescription(144, BufferUsage.UniformBuffer | BufferUsage.Dynamic)); - _resourceSet = factory.CreateResourceSet(new ResourceSetDescription(_layout, _uniformBuffer)); - - _commandList = factory.CreateCommandList(); - - _stagingTexture = factory.CreateTexture(TextureDescription.Texture2D( - PickWindowSize, PickWindowSize, 1, 1, PixelFormat.R8G8B8A8UNorm, TextureUsage.Staging)); - _stagingTexture.Name = "Picking Staging Texture"; - - CreateTargets(1, 1); - - // Scissor test enabled: Pick() constrains rasterization to a small window around the - // cursor instead of the full viewport, since only a few pixels around (x, y) are ever - // read back. - var rasterizerState = new RasterizerStateDescription( - FaceCullMode.None, PolygonFillMode.Solid, FrontFace.Clockwise, - depthClipEnabled: true, scissorTestEnabled: true); - - var pipelineDescription = new GraphicsPipelineDescription( - BlendStateDescription.SINGLE_DISABLED, - new DepthStencilStateDescription(true, true, ComparisonKind.LessEqual), - rasterizerState, - PrimitiveTopology.TriangleList, - new ShaderSetDescription([vertexLayout], _shaders), - [_layout], - _framebuffer.OutputDescription, - ResourceBindingModel.Default); - - _pipeline = factory.CreateGraphicsPipeline(ref pipelineDescription); - } - - private void CreateTargets(uint width, uint height) - { - _width = Math.Max(1u, width); - _height = Math.Max(1u, height); - - var factory = _gd.ResourceFactory; - - _colorTexture = factory.CreateTexture(TextureDescription.Texture2D( - _width, _height, 1, 1, PixelFormat.R8G8B8A8UNorm, TextureUsage.RenderTarget | TextureUsage.Sampled)); - _colorTexture.Name = "Picking Color Texture"; - - _depthTexture = factory.CreateTexture(TextureDescription.Texture2D( - _width, _height, 1, 1, PixelFormat.D32FloatS8UInt, TextureUsage.DepthStencil)); - _depthTexture.Name = "Picking Depth Texture"; - - _framebuffer = factory.CreateFramebuffer(new FramebufferDescription(_depthTexture, _colorTexture)); - _framebuffer.Name = "Picking Framebuffer"; - } - - private void Resize(uint width, uint height) - { - width = Math.Max(1u, width); - height = Math.Max(1u, height); - if (_width == width && _height == height) return; - - _framebuffer.Dispose(); - _colorTexture.Dispose(); - _depthTexture.Dispose(); - CreateTargets(width, height); - } - - /// Sentinel returned by when nothing was under the cursor. Entity IDs start at 0 and are used freely, so the background can't be encoded as 0 — it's encoded as all-ones instead. - public const uint NoHit = uint.MaxValue; - - /// Renders every entry into the picking buffer and reads back the nearest non-background ID within pixels of (x, y), or . Entries pass the SAME id for every mesh belonging to one selectable entity (e.g. all of a Moby's bangles/submeshes). - public uint Pick(uint viewportWidth, uint viewportHeight, int x, int y, Matrix4x4 viewProjection, IEnumerable<(IMesh mesh, Matrix4x4 world, uint id)> entries) - { - Resize(viewportWidth, viewportHeight); - - x = Math.Clamp(x, 0, (int)_width - 1); - y = Math.Clamp(y, 0, (int)_height - 1); - - // Rasterization is scissored to a small window around the cursor — everything outside it - // is discarded before shading, so the readback below only ever sees this same window - // (plus whatever the clear color left behind, i.e. NoHit). - uint scissorX = (uint)Math.Clamp(x - PickWindowSize / 2, 0, (int)_width - 1); - uint scissorY = (uint)Math.Clamp(y - PickWindowSize / 2, 0, (int)_height - 1); - uint scissorRight = (uint)Math.Clamp(x - PickWindowSize / 2 + PickWindowSize, 1, (int)_width); - uint scissorBottom = (uint)Math.Clamp(y - PickWindowSize / 2 + PickWindowSize, 1, (int)_height); - uint copyWidth = scissorRight - scissorX; - uint copyHeight = scissorBottom - scissorY; - - _commandList.Begin(); - _commandList.SetFramebuffer(_framebuffer); - _commandList.ClearColorTarget(0, new RgbaFloat(1, 1, 1, 1)); // decodes to NoHit - _commandList.ClearDepthStencil(1f); - _commandList.SetPipeline(_pipeline); - _commandList.SetScissorRect(0, scissorX, scissorY, scissorRight - scissorX, scissorBottom - scissorY); - - Span uniformData = stackalloc byte[144]; - foreach (var (mesh, world, id) in entries) - { - // Degenerate/empty submeshes have no IndexBuffer (Bliss skips allocating one for - // zero-index meshes) — Veldrith's raw SetIndexBuffer doesn't null-check, so drawing - // one crashes. The main forward renderer never hits this because it goes through - // Bliss's higher-level draw path instead of calling SetIndexBuffer directly. - if (mesh.IndexCount == 0) continue; - - MemoryMarshal.Write(uniformData, in viewProjection); - MemoryMarshal.Write(uniformData[64..], in world); - BitConverter.TryWriteBytes(uniformData[128..], id); - - _commandList.UpdateBuffer(_uniformBuffer, 0, uniformData.ToArray()); - _commandList.SetGraphicsResourceSet(0, _resourceSet); - _commandList.SetVertexBuffer(0, mesh.VertexBuffer); - _commandList.SetIndexBuffer(mesh.IndexBuffer, IndexFormat.UInt32); - _commandList.DrawIndexed(mesh.IndexCount); - } - - _commandList.CopyTexture( - _colorTexture, scissorX, scissorY, 0, 0, 0, - _stagingTexture, 0, 0, 0, 0, 0, - copyWidth, copyHeight, 1, 1); - - _commandList.End(); - _gd.SubmitCommands(_commandList); - _gd.WaitForIdle(); - - // Nearest-hit scan: walk the copied window and keep the non-background pixel closest to - // the actual cursor position, so a click that lands a pixel or two off a thin/silhouette - // edge still resolves to the object instead of missing it. Indexed via the view's [x, y] - // indexer (not row*width*4) since a 4-wide R8G8B8A8 staging texture is very likely - // row-padded by the backend, not tightly packed. - MappedResourceView mapped = _gd.Map(_stagingTexture, MapMode.Read); - - uint bestId = NoHit; - int bestDistSq = int.MaxValue; - for (uint wy = 0; wy < copyHeight; wy++) - { - for (uint wx = 0; wx < copyWidth; wx++) - { - byte r = mapped[wx * 4 + 0, wy]; - byte g = mapped[wx * 4 + 1, wy]; - byte b = mapped[wx * 4 + 2, wy]; - byte a = mapped[wx * 4 + 3, wy]; - uint id = (uint)(r | (g << 8) | (b << 16) | (a << 24)); - if (id == NoHit) continue; - - int dx = (int)(scissorX + wx) - x; - int dy = (int)(scissorY + wy) - y; - int distSq = dx * dx + dy * dy; - if (distSq < bestDistSq) - { - bestDistSq = distSq; - bestId = id; - } - } - } - - _gd.Unmap(_stagingTexture); - - return bestId; - } - - public void Dispose() - { - _framebuffer.Dispose(); - _colorTexture.Dispose(); - _depthTexture.Dispose(); - _stagingTexture.Dispose(); - _pipeline.Dispose(); - _resourceSet.Dispose(); - _uniformBuffer.Dispose(); - _layout.Dispose(); - foreach (var shader in _shaders) shader.Dispose(); - _commandList.Dispose(); - GC.SuppressFinalize(this); - } -} diff --git a/ReLunacy.Engine/Rendering/Primitives.cs b/ReLunacy.Engine/Rendering/Primitives.cs deleted file mode 100644 index 0d15ea4..0000000 --- a/ReLunacy.Engine/Rendering/Primitives.cs +++ /dev/null @@ -1,114 +0,0 @@ -using System.Numerics; -using Bliss.CSharp.Geometry.Meshes; -using Bliss.CSharp.Geometry.Meshes.Data; -using Bliss.CSharp.Graphics.VertexTypes; -using Bliss.CSharp.Materials; -using Veldrith; - -namespace ReLunacy.Engine.Rendering; - -public static class Primitives -{ - public static Mesh CreateCube(GraphicsDevice graphicsDevice, Material material, float size = 1.0f) - => CreateBox(graphicsDevice, material, new Vector3(size)); - - /// Same as but with independent X/Y/Z extents — every face's - /// normal/u/v is a single-axis unit vector, so scaling by a non-uniform half-extents vector - /// component-wise still lands each corner at the right axis-aligned offset. - public static Mesh CreateBox(GraphicsDevice graphicsDevice, Material material, Vector3 size) - { - Vector3 h = size * 0.5f; - - (Vector3 normal, Vector3 u, Vector3 v)[] faces = - [ - (new(0, 0, 1), new(1, 0, 0), new(0, 1, 0)), - (new(0, 0, -1), new(-1, 0, 0), new(0, 1, 0)), - (new(0, 1, 0), new(1, 0, 0), new(0, 0, -1)), - (new(0, -1, 0), new(1, 0, 0), new(0, 0, 1)), - (new(1, 0, 0), new(0, 0, -1), new(0, 1, 0)), - (new(-1, 0, 0), new(0, 0, 1), new(0, 1, 0)), - ]; - - Vertex3D[] vertices = new Vertex3D[faces.Length * 4]; - uint[] indices = new uint[faces.Length * 6]; - - for (int f = 0; f < faces.Length; f++) - { - var (normal, u, v) = faces[f]; - Vector3 center = normal * h; - Vector4 tangent = new(u, 1.0f); - - vertices[f * 4 + 0] = new Vertex3D(center - u * h - v * h, new(0, 1), new(0, 1), normal, tangent, Vector4.One); - vertices[f * 4 + 1] = new Vertex3D(center + u * h - v * h, new(1, 1), new(1, 1), normal, tangent, Vector4.One); - vertices[f * 4 + 2] = new Vertex3D(center + u * h + v * h, new(1, 0), new(1, 0), normal, tangent, Vector4.One); - vertices[f * 4 + 3] = new Vertex3D(center - u * h + v * h, new(0, 0), new(0, 0), normal, tangent, Vector4.One); - - uint baseIndex = (uint)(f * 4); - int i = f * 6; - indices[i + 0] = baseIndex + 0; - indices[i + 1] = baseIndex + 1; - indices[i + 2] = baseIndex + 2; - indices[i + 3] = baseIndex + 0; - indices[i + 4] = baseIndex + 2; - indices[i + 5] = baseIndex + 3; - } - - return new Mesh(graphicsDevice, material, new BasicMeshData(vertices, indices)); - } - - /// Builds a single box edge as actual triangle geometry instead of a GPU line - /// primitive — a pair of thin quads crossed in a "+" through the edge's centerline (one quad - /// thin along each of the two axes perpendicular to the edge), so it always presents real - /// screen-space area to a picking pass regardless of view angle, unlike a single flat quad - /// which can go edge-on and disappear. Same cross-section technique Replanetizer uses for - /// volume picking, but built as ONE shared unit-length edge (running -0.5 to +0.5 along local - /// X, constant thickness) meant to be GPU-instanced per real edge (12 per box) with a - /// per-instance Transform supplying that edge's actual length via non-uniform Scale.X — scale - /// is applied in local space before rotation (see Transform.GetMatrix()'s Scale*Rotation* - /// Translation order), so Scale.X always stretches along this mesh's own local length axis - /// regardless of how the instance is subsequently rotated to align with a real box edge. This - /// is what makes thickness constant across boxes of any size, since the thickness axes - /// (Y/Z) are never touched by that per-instance scale — see EntityVolume, which replaced its - /// old per-volume custom box mesh with instances of this one shared mesh. - public static Mesh CreateWireEdge(GraphicsDevice graphicsDevice, Material material, float thickness) - { - float t = MathF.Max(thickness, 0.001f) * 0.5f; - - var vertices = new List(8); - var indices = new List(12); - - void AddQuad(Vector3 axisThin, Vector3 normal) - { - Vector3 lengthExt = Vector3.UnitX * 0.5f; - Vector3 thinExt = axisThin * t; - Vector4 tangent = new(Vector3.UnitX, 1.0f); - - uint baseIndex = (uint)vertices.Count; - vertices.Add(new Vertex3D(-lengthExt - thinExt, new(0, 1), new(0, 1), normal, tangent, Vector4.One)); - vertices.Add(new Vertex3D(lengthExt - thinExt, new(1, 1), new(1, 1), normal, tangent, Vector4.One)); - vertices.Add(new Vertex3D(lengthExt + thinExt, new(1, 0), new(1, 0), normal, tangent, Vector4.One)); - vertices.Add(new Vertex3D(-lengthExt + thinExt, new(0, 0), new(0, 0), normal, tangent, Vector4.One)); - - // The two AddQuad calls below don't share a consistent (length, thin, normal) - // handedness — for one of them, UnitX x axisThin points opposite the declared - // `normal`. Flip the two triangles' winding in that case so the front face (by the - // standard CCW-from-outside convention) always actually faces `normal`, instead of - // silently depending on RasterizerState being CULL_NONE to hide the mismatch. - if (Vector3.Dot(Vector3.Cross(Vector3.UnitX, axisThin), normal) < 0f) - { - indices.Add(baseIndex + 0); indices.Add(baseIndex + 2); indices.Add(baseIndex + 1); - indices.Add(baseIndex + 0); indices.Add(baseIndex + 3); indices.Add(baseIndex + 2); - } - else - { - indices.Add(baseIndex + 0); indices.Add(baseIndex + 1); indices.Add(baseIndex + 2); - indices.Add(baseIndex + 0); indices.Add(baseIndex + 2); indices.Add(baseIndex + 3); - } - } - - AddQuad(Vector3.UnitY, Vector3.UnitZ); - AddQuad(Vector3.UnitZ, Vector3.UnitY); - - return new Mesh(graphicsDevice, material, new BasicMeshData([.. vertices], [.. indices])); - } -} diff --git a/ReLunacy.Engine/Rendering/Resources/GpuTexture.cs b/ReLunacy.Engine/Rendering/Resources/GpuTexture.cs new file mode 100644 index 0000000..6c0baa4 --- /dev/null +++ b/ReLunacy.Engine/Rendering/Resources/GpuTexture.cs @@ -0,0 +1,168 @@ +using Veldrith; + +namespace ReLunacy.Engine.Rendering.Resources; + +/// A decoded texture and its full mip chain, in main memory, ready to be uploaded. +/// +/// Split out from so the expensive half can be done off the main thread. +/// Generating the chain for a level's worth of textures was measured at ~4.5s on metropolis, all of it +/// plain arithmetic over byte arrays with nothing graphics-related in it, so it parallelises across +/// cores and leaves only the upload itself on the thread that owns the device. +public sealed class TextureLevels +{ + public uint Width { get; } + public uint Height { get; } + + /// Mip levels from largest to smallest. Level 0 is the source image. + public byte[][] Levels { get; } + + private TextureLevels(uint width, uint height, byte[][] levels) + { + Width = width; + Height = height; + Levels = levels; + } + + /// Tightly packed RGBA8, width * height * 4 bytes. + /// Off for anything sampled at a fixed scale (lightmap atlases, UI previews), + /// where a chain is wasted memory and bleeds across atlas cells. + public static TextureLevels Prepare(uint width, uint height, byte[] rgba, bool mipmap = true) + { + ArgumentOutOfRangeException.ThrowIfZero(width); + ArgumentOutOfRangeException.ThrowIfZero(height); + if (rgba.Length < (long)width * height * 4) + throw new ArgumentException($"Expected {(long)width * height * 4} bytes of RGBA8, got {rgba.Length}.", nameof(rgba)); + + uint count = mipmap ? CountMipLevels(width, height) : 1; + var levels = new byte[count][]; + levels[0] = rgba; + + uint lw = width, lh = height; + for (uint mip = 1; mip < count; mip++) + { + levels[mip] = Downsample(levels[mip - 1], lw, lh, out lw, out lh); + } + return new TextureLevels(width, height, levels); + } + + /// A full chain down to 1x1, which is what a sampler with no LOD clamp expects to find. + private static uint CountMipLevels(uint width, uint height) + { + uint levels = 1; + while (width > 1 || height > 1) + { + width = Math.Max(1u, width / 2); + height = Math.Max(1u, height / 2); + levels++; + } + return levels; + } + + /// Box filter over each 2x2 block. + /// + /// Two paths on purpose. Even dimensions (every texture this game actually ships, being powers of + /// two) need no bounds handling at all, so the inner loop is four straight reads. Odd dimensions + /// halve down to the floor and clamp, which drops the last row or column; exact enough, and it is + /// the case that never happens on real content. + private static byte[] Downsample(byte[] src, uint width, uint height, out uint outWidth, out uint outHeight) + { + outWidth = Math.Max(1u, width / 2); + outHeight = Math.Max(1u, height / 2); + + var dst = new byte[outWidth * outHeight * 4]; + bool exact = width >= 2 && height >= 2 && (width & 1) == 0 && (height & 1) == 0; + + for (uint y = 0; y < outHeight; y++) + { + uint sy0 = exact ? y * 2 : Math.Min(y * 2, height - 1); + uint sy1 = exact ? sy0 + 1 : Math.Min(sy0 + 1, height - 1); + uint row0 = sy0 * width, row1 = sy1 * width; + uint o = y * outWidth * 4; + + for (uint x = 0; x < outWidth; x++, o += 4) + { + uint sx0 = exact ? x * 2 : Math.Min(x * 2, width - 1); + uint sx1 = exact ? sx0 + 1 : Math.Min(sx0 + 1, width - 1); + + uint i00 = (row0 + sx0) * 4, i01 = (row0 + sx1) * 4; + uint i10 = (row1 + sx0) * 4, i11 = (row1 + sx1) * 4; + + dst[o] = (byte)((src[i00] + src[i01] + src[i10] + src[i11] + 2) >> 2); + dst[o + 1] = (byte)((src[i00 + 1] + src[i01 + 1] + src[i10 + 1] + src[i11 + 1] + 2) >> 2); + dst[o + 2] = (byte)((src[i00 + 2] + src[i01 + 2] + src[i10 + 2] + src[i11 + 2] + 2) >> 2); + dst[o + 3] = (byte)((src[i00 + 3] + src[i01 + 3] + src[i10 + 3] + src[i11 + 3] + 2) >> 2); + } + } + return dst; + } +} + +/// An RGBA8 texture resident on the GPU, with its mip chain. +/// +/// The one asset type that still owns a real graphics resource, because the renderer samples it +/// directly. Construction is the upload only: the decoding and mip generation happen in +/// , which can be done on any thread beforehand. +public sealed class GpuTexture : IDisposable +{ + public uint Width { get; } + public uint Height { get; } + public uint MipLevels { get; } + public Texture DeviceTexture { get; private set; } + + /// Uploads an already-prepared chain. Must run on the thread that owns the device. + public GpuTexture(GraphicsDevice graphicsDevice, TextureLevels prepared) + : this(graphicsDevice, prepared.Width, prepared.Height, (uint)prepared.Levels.Length) + { + UploadAll(graphicsDevice, prepared); + } + + /// Allocates the device texture only - no pixel data yet, so DeviceTexture's content is + /// undefined until / runs. CreateTexture is a plain + /// image+memory allocation (no queue submission), so this is cheap and does not need staging: the + /// point is to let a caller hand out a valid Texture reference immediately, then perform however + /// many mips' worth of actual GraphicsDevice.UpdateTexture calls later, spread across as many + /// frames as it wants instead of paying for all of them in one blocking call - see + /// AssetManager.UploadOnePendingTexture, which is what this exists for. + public GpuTexture(GraphicsDevice graphicsDevice, uint width, uint height, uint mipLevels) + { + Width = width; + Height = height; + MipLevels = mipLevels; + DeviceTexture = graphicsDevice.ResourceFactory.CreateTexture(TextureDescription.Texture2D( + Width, Height, MipLevels, 1, PixelFormat.R8G8B8A8UNorm, TextureUsage.Sampled)); + } + + /// Uploads every mip of an already-prepared chain in one call - the original, immediate, + /// fully-synchronous behaviour, still used for anything not going through the deferred queue. + public void UploadAll(GraphicsDevice graphicsDevice, TextureLevels prepared) + { + uint w = Width, h = Height; + for (uint mip = 0; mip < MipLevels; mip++) + { + UploadMip(graphicsDevice, mip, prepared.Levels[mip], w, h); + w = Math.Max(1u, w / 2); + h = Math.Max(1u, h / 2); + } + } + + /// Uploads exactly one mip level - the same GraphicsDevice.UpdateTexture call UploadAll + /// makes in its loop, exposed so a caller can spread a texture's mips (or many textures) across + /// multiple frames instead of blocking through all of them at once. + public void UploadMip(GraphicsDevice graphicsDevice, uint mip, byte[] data, uint mipWidth, uint mipHeight) => + graphicsDevice.UpdateTexture(DeviceTexture, data, 0, 0, 0, mipWidth, mipHeight, 1, mip, 0); + + /// Prepares and uploads in one step, for callers with a single texture and no reason to + /// stage the work (previews, the 1x1 fallbacks). + public GpuTexture(GraphicsDevice graphicsDevice, uint width, uint height, byte[] rgba, bool mipmap = true) + : this(graphicsDevice, TextureLevels.Prepare(width, height, rgba, mipmap)) { } + + /// A 1x1 texture of one colour, for the flat fallbacks every material slot needs bound. + public static GpuTexture Solid(GraphicsDevice graphicsDevice, byte r, byte g, byte b, byte a) => + new(graphicsDevice, 1, 1, [r, g, b, a], mipmap: false); + + public void Dispose() + { + DeviceTexture?.Dispose(); + DeviceTexture = null!; + } +} diff --git a/ReLunacy.Engine/Rendering/Resources/Image.cs b/ReLunacy.Engine/Rendering/Resources/Image.cs new file mode 100644 index 0000000..5c9bcd1 --- /dev/null +++ b/ReLunacy.Engine/Rendering/Resources/Image.cs @@ -0,0 +1,96 @@ +using StbImageSharp; +using StbImageWriteSharp; + +namespace ReLunacy.Engine.Rendering.Resources; + +/// One RGBA8 colour. Byte channels because that is what both the game's decoded textures and +/// the PNG encoder work in; anything wanting floats goes through . +public readonly record struct RgbaColor(byte R, byte G, byte B, byte A) +{ + public static readonly RgbaColor White = new(255, 255, 255, 255); + public static readonly RgbaColor Black = new(0, 0, 0, 255); + public static readonly RgbaColor Transparent = new(0, 0, 0, 0); + + public System.Numerics.Vector4 ToVector4() => new(R / 255f, G / 255f, B / 255f, A / 255f); +} + +/// A CPU-side RGBA8 bitmap: decode a file into one, edit pixels, encode it back out. +/// +/// Nothing here touches the GPU. is the other half, and takes the raw +/// directly. +public sealed class Image +{ + public int Width { get; private set; } + public int Height { get; private set; } + + /// Tightly packed RGBA8, row-major from the top left. Length is always Width*Height*4. + public byte[] Data { get; private set; } + + public Image(int width, int height, byte[] rgba) + { + if (rgba.Length < width * height * 4) + throw new ArgumentException($"Expected {width * height * 4} bytes of RGBA8, got {rgba.Length}.", nameof(rgba)); + Width = width; + Height = height; + Data = rgba; + } + + public Image(int width, int height, RgbaColor fill) + { + Width = width; + Height = height; + Data = new byte[width * height * 4]; + for (int i = 0; i < Data.Length; i += 4) + { + Data[i] = fill.R; + Data[i + 1] = fill.G; + Data[i + 2] = fill.B; + Data[i + 3] = fill.A; + } + } + + /// Decodes an encoded image (PNG, JPEG, BMP, TGA, ...) from its file bytes, always to + /// RGBA8 whatever the source channel count was. + public Image(byte[] encoded) + { + var result = ImageResult.FromMemory(encoded, StbImageSharp.ColorComponents.RedGreenBlueAlpha) + ?? throw new ArgumentException("Could not decode image data.", nameof(encoded)); + Width = result.Width; + Height = result.Height; + Data = result.Data; + } + + public Image(string path) : this(File.ReadAllBytes(path)) { } + + public RgbaColor GetColor(int x, int y) + { + int i = (y * Width + x) * 4; + return new RgbaColor(Data[i], Data[i + 1], Data[i + 2], Data[i + 3]); + } + + public void SetPixel(int x, int y, RgbaColor color) + { + int i = (y * Width + x) * 4; + Data[i] = color.R; + Data[i + 1] = color.G; + Data[i + 2] = color.B; + Data[i + 3] = color.A; + } + + public Image Clone() => new(Width, Height, (byte[])Data.Clone()); + + /// PNG bytes, in memory. This used to be reachable only by writing a temp file and + /// reading it back, because the encoder was behind a path-only API. + public byte[] EncodeToPng() + { + using var stream = new MemoryStream(); + new ImageWriter().WritePng(Data, Width, Height, StbImageWriteSharp.ColorComponents.RedGreenBlueAlpha, stream); + return stream.ToArray(); + } + + public void SaveAsPng(string path) + { + using var stream = File.Create(path); + new ImageWriter().WritePng(Data, Width, Height, StbImageWriteSharp.ColorComponents.RedGreenBlueAlpha, stream); + } +} diff --git a/ReLunacy.Engine/Rendering/Resources/MaterialMap.cs b/ReLunacy.Engine/Rendering/Resources/MaterialMap.cs new file mode 100644 index 0000000..e576325 --- /dev/null +++ b/ReLunacy.Engine/Rendering/Resources/MaterialMap.cs @@ -0,0 +1,41 @@ +namespace ReLunacy.Engine.Rendering.Resources; + +/// The eight named slots every built material fills. Only Albedo and Normal keep their +/// conventional meaning here; the rest of the game's inputs are addressed by name instead (see +/// AssetManager.GetOrBuildMaterial for the full list). +public enum MaterialMapType +{ + Albedo, + Metallic, + Normal, + Roughness, + Occlusion, + Emission, + Opacity, + Height, +} + +/// Names a slot on a . Implicitly convertible from both a +/// and a plain string, so the game's own inputs ("fLightColour", +/// "fParallaxScale", ...) sit in the same dictionary as the conventional ones. +public readonly record struct MaterialMapKey(string Name) +{ + public MaterialMapKey(MaterialMapType type) : this(type.ToString()) { } + + public static implicit operator MaterialMapKey(MaterialMapType type) => new(type); + public static implicit operator MaterialMapKey(string name) => new(name); + + public override string ToString() => Name; +} + +/// One slot's contents: a texture, the sampler to read it with, and a scalar. +/// +/// The scalar is not decoration. Several of the game's per-material constants (alpha-clip threshold, +/// parallax scale/bias, detail tiling, "this material has a real bake") are carried in it, which is why +/// a map with no texture at all is still worth storing. +public sealed class MaterialMap(GpuTexture? texture = null, Veldrith.Sampler? sampler = null, float value = 0f) +{ + public GpuTexture? Texture = texture; + public Veldrith.Sampler? Sampler = sampler; + public float Value = value; +} diff --git a/ReLunacy.Engine/Rendering/Resources/RenderMaterial.cs b/ReLunacy.Engine/Rendering/Resources/RenderMaterial.cs new file mode 100644 index 0000000..114620f --- /dev/null +++ b/ReLunacy.Engine/Rendering/Resources/RenderMaterial.cs @@ -0,0 +1,50 @@ +namespace ReLunacy.Engine.Rendering.Resources; + +/// A built material: the named slots the renderer samples, keyed by +/// . +/// +/// Deliberately pure data. It used to be a Bliss Material, which also carried an Effect (a compiled +/// shader with its own pipeline layouts), a RasterizerStateDescription and a BlendStateDescription. +/// The raw-Vulkan renderer compiles its own shaders and derives every blend/depth/raster state from the +/// game's own render mode instead (see GameRenderMode), so all three were describing state nothing +/// read any more. +public sealed class RenderMaterial +{ + private readonly Dictionary _maps = []; + + /// Set when a slot's texture or value changes, so a consumer holding derived GPU state can + /// notice. Cleared by whoever acts on it. + public bool IsDirty { get; set; } + + /// Free-form per-material scalars, unused by the renderer and kept for the inspector. + public List Parameters { get; } = []; + + public void AddMaterialMap(MaterialMapKey key, MaterialMap map) + { + _maps[key] = map; + IsDirty = true; + } + + public MaterialMap? GetMaterialMap(MaterialMapKey key) => _maps.GetValueOrDefault(key); + + public IEnumerable GetMaterialMapKeys() => _maps.Keys; + public IEnumerable GetMaterialMaps() => _maps.Values; + + public GpuTexture? GetMapTexture(MaterialMapKey key) => _maps.GetValueOrDefault(key)?.Texture; + + public void SetMapTexture(MaterialMapKey key, GpuTexture? texture) + { + if (!_maps.TryGetValue(key, out var map)) return; + map.Texture = texture; + IsDirty = true; + } + + public float GetMapValue(MaterialMapKey key) => _maps.GetValueOrDefault(key)?.Value ?? 0f; + + public void SetMapValue(MaterialMapKey key, float value) + { + if (!_maps.TryGetValue(key, out var map)) return; + map.Value = value; + IsDirty = true; + } +} diff --git a/ReLunacy.Engine/Rendering/Resources/RenderMesh.cs b/ReLunacy.Engine/Rendering/Resources/RenderMesh.cs new file mode 100644 index 0000000..09056b9 --- /dev/null +++ b/ReLunacy.Engine/Rendering/Resources/RenderMesh.cs @@ -0,0 +1,27 @@ +namespace ReLunacy.Engine.Rendering.Resources; + +/// One drawable piece of geometry and the material it is drawn with. +/// +/// It holds no GPU buffers. The raw-Vulkan renderer uploads geometry itself, out of +/// , which is keyed by the mesh INSTANCE: every placement of a +/// model shares one RenderMesh and therefore one uploaded copy. The Bliss mesh this replaced also +/// created a vertex and an index buffer of its own, so the whole level used to be resident on the GPU +/// twice over. +public sealed class RenderMesh(Vertex3D[] vertices, uint[] indices, RenderMaterial material) +{ + public Vertex3D[] Vertices { get; } = vertices; + public uint[] Indices { get; } = indices; + public RenderMaterial Material { get; set; } = material; + + public int VertexCount => Vertices.Length; + public int IndexCount => Indices.Length; +} + +/// A model's meshes, in the order the asset defines them. +/// +/// Bangles index into this (see EntityMoby), so the order matters and a mesh that failed to build is +/// still worth a slot rather than being dropped. +public sealed class RenderModel(RenderMesh[] meshes) +{ + public RenderMesh[] Meshes { get; } = meshes; +} diff --git a/ReLunacy.Engine/Rendering/Resources/Renderable.cs b/ReLunacy.Engine/Rendering/Resources/Renderable.cs new file mode 100644 index 0000000..d3066be --- /dev/null +++ b/ReLunacy.Engine/Rendering/Resources/Renderable.cs @@ -0,0 +1,39 @@ +namespace ReLunacy.Engine.Rendering.Resources; + +/// One mesh placed in the world, with an optional per-placement material override. +/// +/// The override is what lit ties need: one tie model is shared across many placements, but each +/// placement has its own baked lightmap textures, so the material cannot live on the shared mesh. +/// +/// Like , this is pure data now. The Bliss Renderable it replaced allocated a +/// transform uniform buffer, an instance vertex buffer, a bone buffer and a material uniform buffer per +/// placement, all of which existed to feed a renderer that no longer runs. +public sealed class Renderable +{ + public RenderMesh Mesh { get; } + + /// The per-placement override if there is one, otherwise the mesh's own material. + public RenderMaterial Material { get; set; } + + private Transform[] _transforms; + + public Renderable(RenderMesh mesh, Transform transform, RenderMaterial? material = null) + { + Mesh = mesh; + Material = material ?? mesh.Material; + _transforms = [transform]; + } + + public Renderable(RenderMesh mesh, Transform[] transforms, RenderMaterial? material = null) + { + Mesh = mesh; + Material = material ?? mesh.Material; + _transforms = transforms.Length > 0 ? transforms : [new Transform()]; + } + + public int InstanceCount => _transforms.Length; + + public ReadOnlySpan GetTransforms() => _transforms; + + public void SetTransforms(Transform[] transforms) => _transforms = transforms; +} diff --git a/ReLunacy.Engine/Rendering/Resources/Transform.cs b/ReLunacy.Engine/Rendering/Resources/Transform.cs new file mode 100644 index 0000000..1cccf58 --- /dev/null +++ b/ReLunacy.Engine/Rendering/Resources/Transform.cs @@ -0,0 +1,35 @@ +using System.Numerics; + +namespace ReLunacy.Engine.Rendering.Resources; + +/// Position, orientation and scale of one placement in the world. +/// +/// A reference type on purpose. Entities hand the same Transform to every renderable they build, and a +/// gizmo edit ASSIGNS a new one (see GizmoController) rather than mutating in place, which is what +/// exists to notice. +public sealed class Transform : IEquatable +{ + public Vector3 Translation; + public Quaternion Rotation = Quaternion.Identity; + public Vector3 Scale = Vector3.One; + + public Vector3 Forward => Vector3.Transform(-Vector3.UnitZ, Rotation); + public Vector3 Up => Vector3.Transform(Vector3.UnitY, Rotation); + public Vector3 Right => Vector3.Transform(Vector3.UnitX, Rotation); + + /// Scale, then rotation, then translation. The order is load-bearing: a volume's wireframe + /// edges (EntityVolume.ComposeEdgeTransform) rely on Scale being applied in LOCAL space, so a + /// per-edge Scale.X stretches the edge along its own length axis no matter how it is later + /// rotated to line up with a real box edge. + public Matrix4x4 GetMatrix() => + Matrix4x4.CreateScale(Scale) + * Matrix4x4.CreateFromQuaternion(Rotation) + * Matrix4x4.CreateTranslation(Translation); + + public bool Equals(Transform? other) => + other is not null && Translation.Equals(other.Translation) && Rotation.Equals(other.Rotation) && Scale.Equals(other.Scale); + + public override bool Equals(object? obj) => Equals(obj as Transform); + public override int GetHashCode() => HashCode.Combine(Translation, Rotation, Scale); + public override string ToString() => $"T:{Translation} R:{Rotation} S:{Scale}"; +} diff --git a/ReLunacy.Engine/Rendering/Resources/Vertex3D.cs b/ReLunacy.Engine/Rendering/Resources/Vertex3D.cs new file mode 100644 index 0000000..25bc610 --- /dev/null +++ b/ReLunacy.Engine/Rendering/Resources/Vertex3D.cs @@ -0,0 +1,19 @@ +using System.Numerics; + +namespace ReLunacy.Engine.Rendering.Resources; + +/// One vertex as the asset pipeline produces it, before +/// packs it into the renderer's layout. +/// +/// is the lightmap UV set, and carries the bitangent +/// handedness in W. is the per-vertex colour, whose alpha is the opacity for +/// materials flagged UsesVertexAlpha. +public struct Vertex3D(Vector3 position, Vector2 texCoords, Vector2 texCoords2, Vector3 normal, Vector4 tangent, Vector4 color) +{ + public Vector3 Position = position; + public Vector2 TexCoords = texCoords; + public Vector2 TexCoords2 = texCoords2; + public Vector3 Normal = normal; + public Vector4 Tangent = tangent; + public Vector4 Color = color; +} diff --git a/ReLunacy.Engine/Rendering/SceneLighting.cs b/ReLunacy.Engine/Rendering/SceneLighting.cs new file mode 100644 index 0000000..1dc725b --- /dev/null +++ b/ReLunacy.Engine/Rendering/SceneLighting.cs @@ -0,0 +1,86 @@ +using System.Numerics; +using Veldrith; + +namespace ReLunacy.Engine.Rendering; + +/// The scene's lighting state, and the the shaders consume. +/// +/// This used to live on DecalAwareForwardRenderer, which meant the raw-Vulkan renderer could only get +/// at the level's lighting by going through a Bliss renderer it otherwise no longer uses. It is plain +/// state with one pure builder, so it belongs on its own: only touches +/// the graphics API at all, and that is a Veldrith type, not a Bliss one. +/// +/// Values default to a plain downward light; the view pushes the real EditorSettings values every +/// frame, same pattern as Camera.FarPlane / VolumeWireThickness. +public sealed class SceneLighting +{ + public Vector3 LightDirection = new(-0.4f, -0.8f, 0.3f); + public Vector3 LightColor = Vector3.One; + public float Ambient = 0.15f; + public float SpecularPower = 32f; + + /// Averaged from the level's own cubemap at load; see LightData.EnvironmentColour. + /// Intensity defaults to 0 so nothing changes until a level actually supplies one. + public Vector3 EnvironmentColour = Vector3.One; + public float EnvironmentIntensity; + + /// Debug: draw the raw cubemap reflection on everything (see LightData.ReflectionDebugView). + public bool ReflectionDebugView; + + /// Fresnel F0 for the cubemap reflection (see LightData.ReflectionBase). 0 = specular-map-gated. + public float ReflectionBase; + + /// The level's analytic lighting environment (section 0x8b00), pushed from + /// LevelData.LightingEnvironment. Lights undecoded (non-baked) surfaces with the game's own + /// sun/ambient. stays false for levels without one. + public bool HasLightingEnvironment; + public Vector3 EnvDirection0 = Vector3.UnitY; + public Vector3 EnvDirection1 = Vector3.UnitY; + public Vector3 EnvAmbient; + public Vector3 EnvLight0Colour; + public Vector3 EnvLight1Colour; + + /// The level's environment cubemap (AssetManager.EnvironmentCubemapView), sampled for + /// reflections. Scene-wide; the view pushes it each frame like EnvironmentColour. + public TextureView? EnvironmentCubemap; + + // Live lightmap research controls - see LightData for what each one stands in for. + public Vector2 LightmapUVScale = Vector2.One; + public Vector2 LightmapUVOffset = Vector2.Zero; + public float BakedLightScale = 4f; + public float BakedBumpFade = 1f; + /// Fraction of the ambient fill kept under a baked surface (see LightData.BakedAmbient). + /// Defaults low but non-zero: enough to keep parallax crevices off pure black without washing out + /// the bake's own shadows. + public float BakedAmbient = 0.15f; + public bool BakedDebugView; + public Vector2 LightmapUVPivot = new(0.5f, 0.5f); + public float LightmapUVRotation; + + public LightData BuildLightData(Vector3 cameraPosition) => new() + { + Direction = LightDirection.LengthSquared() > 0f ? Vector3.Normalize(LightDirection) : Vector3.UnitY, + Ambient = Ambient, + Color = LightColor, + SpecularPower = MathF.Max(SpecularPower, 1f), + CameraPosition = cameraPosition, + ReflectionDebugView = ReflectionDebugView ? 1f : 0f, + ReflectionBase = ReflectionBase, + EnvironmentColour = EnvironmentColour, + EnvironmentIntensity = EnvironmentIntensity, + LightmapUVScale = LightmapUVScale, + LightmapUVOffset = LightmapUVOffset, + BakedLightScale = BakedLightScale, + BakedBumpFade = BakedBumpFade, + BakedAmbient = BakedAmbient, + BakedDebugView = BakedDebugView ? 1f : 0f, + LightmapUVPivot = LightmapUVPivot, + LightmapUVRotation = LightmapUVRotation, + EnvHasLighting = HasLightingEnvironment ? 1f : 0f, + EnvDirection0 = EnvDirection0, + EnvDirection1 = EnvDirection1, + EnvAmbient = EnvAmbient, + EnvLight0Colour = EnvLight0Colour, + EnvLight1Colour = EnvLight1Colour, + }; +} diff --git a/ReLunacy.Engine/Rendering/SceneRenderer.cs b/ReLunacy.Engine/Rendering/SceneRenderer.cs deleted file mode 100644 index f66a1f3..0000000 --- a/ReLunacy.Engine/Rendering/SceneRenderer.cs +++ /dev/null @@ -1,69 +0,0 @@ -using System.Numerics; -using Bliss.CSharp.Camera.Dim3; -using Bliss.CSharp.Geometry; -using Bliss.CSharp.Geometry.Meshes; -using Bliss.CSharp.Graphics.Rendering; -using Bliss.CSharp.Graphics.Rendering.Renderers.Forward; -using Bliss.CSharp.Transformations; -using Veldrith; - -namespace ReLunacy.Engine.Rendering; - -public sealed class SceneRenderer : IDisposable -{ - private readonly IRenderer _renderer; - private Frustum? _frustum; - - public int SubmittedCount { get; private set; } - public int CulledCount { get; private set; } - - public SceneRenderer(GraphicsDevice graphicsDevice) - { - _renderer = new DecalAwareForwardRenderer(graphicsDevice); - } - - public void BeginFrame(Cam3D camera) - { - _frustum = camera.GetFrustum(); - SubmittedCount = 0; - CulledCount = 0; - } - - public void Submit(IMesh mesh, Transform transform, bool copyMeshMaterial = false) - { - if (!IsVisible(mesh.GenBoundingBox(), transform)) - { - CulledCount++; - return; - } - - SubmittedCount++; - _renderer.DrawRenderable(new Renderable(mesh, transform, copyMeshMaterial)); - } - - private bool IsVisible(BoundingBox localBounds, Transform transform) - { - if (_frustum is null) - return true; - - Matrix4x4 world = transform.GetMatrix(); - - Vector3 min = new(float.MaxValue), max = new(float.MinValue); - for (int i = 0; i < 8; i++) - { - Vector3 corner = new( - (i & 1) == 0 ? localBounds.Min.X : localBounds.Max.X, - (i & 2) == 0 ? localBounds.Min.Y : localBounds.Max.Y, - (i & 4) == 0 ? localBounds.Min.Z : localBounds.Max.Z); - Vector3 worldCorner = Vector3.Transform(corner, world); - min = Vector3.Min(min, worldCorner); - max = Vector3.Max(max, worldCorner); - } - - return _frustum.ContainsBox(new BoundingBox(min, max)); - } - - public void Draw(CommandList commandList, OutputDescription output) => _renderer.Draw(commandList, output); - - public void Dispose() => _renderer.Dispose(); -} diff --git a/ReLunacy.Engine/Rendering/SelectionOutlineRenderer.cs b/ReLunacy.Engine/Rendering/SelectionOutlineRenderer.cs deleted file mode 100644 index ebee816..0000000 --- a/ReLunacy.Engine/Rendering/SelectionOutlineRenderer.cs +++ /dev/null @@ -1,262 +0,0 @@ -using System.Numerics; -using System.Runtime.InteropServices; -using System.Text; -using Bliss.CSharp.Geometry.Meshes; -using Veldrith; -using Veldrith.SPIRV; - -namespace ReLunacy.Engine.Rendering; - -// Draws a solid silhouette outline around the selected entity's mesh(es), replacing the old -// bounding-sphere wireframe highlight. -// -// This is a two-pass stencil "mask and inflate" technique, not the more common single-pass -// inflated-backface-hull trick — that one relies on either consistent triangle winding (to cull -// the hull's front faces at the GPU level) or consistent vertex normals (to discard them in the -// fragment shader instead). Both were tried here and both broke: AssetManager builds every -// Moby/Tie material with RasterizerStateDescription.CULL_NONE specifically because winding in -// these source assets isn't trustworthy, and it turns out vertex normals aren't consistently -// outward-facing either (same underlying data quality issue) — the normal-based version showed a -// correct rim on part of a mesh and a solid filled blob over the rest, exactly where normals -// were inconsistent. -// -// This version drops the winding dependency entirely: -// - Pass 1 ("mask") redraws the real, un-inflated mesh with color writes disabled, stamping a -// stencil value of 1 everywhere it's actually visible (depth-tested against the already- -// rendered scene, so occluded parts correctly don't get marked). This is just the mesh's own -// on-screen footprint — it doesn't care which way any triangle faces, so a selected object's -// own texture can no longer be painted over by its own outline. -// - Pass 2 ("outline") redraws the mesh again, inflated in clip space, with the stencil test set -// to pass only where the buffer is NOT already 1 — i.e. everywhere the inflated hull sticks out -// past the real mesh's footprint from pass 1. That's the rim. -// The inflation direction in pass 2 is still per-vertex-normal, so it inherits whatever normal -// inconsistency the source mesh has — on a model with unreliable normals this can still show up -// as a thin/patchy rim in places (never as a blob covering the object, since the mask makes that -// specific failure impossible). If that turns out to be visible, the fix is to inflate uniformly -// from the mesh's local bounding-sphere center instead of along normals. -// The stencil buffer is cleared to 0 once per frame by View3D's existing ClearDepthStencil call; -// nothing else in the normal render path writes to stencil, so no extra clear is needed here. -public sealed class SelectionOutlineRenderer : IDisposable -{ - private const string VertSource = """ - #version 450 - - layout(std140, set = 0, binding = 0) uniform OutlineBuffer { - mat4 uViewProjection; - mat4 uWorld; - vec4 uColor; - vec4 uThickness; // x = clip-space inflate amount for this pass (0 for the mask pass) - }; - - layout(location = 0) in vec3 vPosition; - layout(location = 1) in vec2 vTexCoords; - layout(location = 2) in vec2 vTexCoords2; - layout(location = 3) in vec3 vNormal; - layout(location = 4) in vec4 vTangent; - layout(location = 5) in vec4 vColor; - - void main() { - vec4 clipPos = uViewProjection * uWorld * vec4(vPosition, 1.0); - - if (uThickness.x > 0.0) { - vec4 clipNormal = uViewProjection * uWorld * vec4(vNormal, 0.0); - if (length(clipNormal.xy) > 0.0001) - clipPos.xy += normalize(clipNormal.xy) * uThickness.x * clipPos.w; - } else { - // Mask pass (thickness == 0): redraws the same geometry the main opaque pass - // already wrote depth for, and the mask's LessEqual test needs to reliably win - // against that existing depth. Two separate draw calls of "the same" vertices - // aren't guaranteed bit-identical depth after going through separate shader - // invocations/pipelines, so without a bias the comparison intermittently fails - // by camera angle — stencil doesn't get stamped, and the outline pass fills the - // unmasked interior solid. Nudging slightly toward the camera fixes that; it's - // far smaller than any real occlusion gap, so genuine occlusion still masks out. - clipPos.z -= 0.0005 * clipPos.w; - } - - gl_Position = clipPos; - } - """; - - private const string FragSource = """ - #version 450 - - layout(std140, set = 0, binding = 0) uniform OutlineBuffer { - mat4 uViewProjection; - mat4 uWorld; - vec4 uColor; - vec4 uThickness; - }; - - layout(location = 0) out vec4 fFragColor; - - void main() { - fFragColor = uColor; - } - """; - - private readonly GraphicsDevice _gd; - private readonly Shader[] _shaders; - private readonly VertexLayoutDescription _vertexLayout; - private readonly ResourceLayout _layout; - private readonly DeviceBuffer _uniformBuffer; - private readonly ResourceSet _resourceSet; - - private Pipeline? _maskPipeline; - private Pipeline? _maskDebugPipeline; - private Pipeline? _outlinePipeline; - private OutputDescription _pipelineOutputDescription; - - public SelectionOutlineRenderer(GraphicsDevice gd) - { - _gd = gd; - var factory = gd.ResourceFactory; - - _shaders = factory.CreateFromSpirv( - new ShaderDescription(ShaderStages.Vertex, Encoding.UTF8.GetBytes(VertSource), "main"), - new ShaderDescription(ShaderStages.Fragment, Encoding.UTF8.GetBytes(FragSource), "main")); - - _vertexLayout = new VertexLayoutDescription( - new VertexElementDescription("vPosition", VertexElementSemantic.TextureCoordinate, VertexElementFormat.Float3), - new VertexElementDescription("vTexCoords", VertexElementSemantic.TextureCoordinate, VertexElementFormat.Float2), - new VertexElementDescription("vTexCoords2", VertexElementSemantic.TextureCoordinate, VertexElementFormat.Float2), - new VertexElementDescription("vNormal", VertexElementSemantic.TextureCoordinate, VertexElementFormat.Float3), - new VertexElementDescription("vTangent", VertexElementSemantic.TextureCoordinate, VertexElementFormat.Float4), - new VertexElementDescription("vColor", VertexElementSemantic.TextureCoordinate, VertexElementFormat.Float4)); - - _layout = factory.CreateResourceLayout(new ResourceLayoutDescription( - new ResourceLayoutElementDescription("OutlineBuffer", ResourceKind.UniformBuffer, ShaderStages.Vertex | ShaderStages.Fragment))); - - // 2 * mat4 (64 bytes each) + vec4 + vec4, all 16-byte aligned already. - _uniformBuffer = factory.CreateBuffer(new BufferDescription(160, BufferUsage.UniformBuffer | BufferUsage.Dynamic)); - _resourceSet = factory.CreateResourceSet(new ResourceSetDescription(_layout, _uniformBuffer)); - } - - // Pipelines depend on the target framebuffer's format/sample count (MSAA), which can differ - // between View3D's and AssetViewer's render textures — build/rebuild lazily instead of - // assuming one fixed OutputDescription for the renderer's lifetime. - private void EnsurePipelines(OutputDescription outputDescription) - { - if (_maskPipeline != null && _pipelineOutputDescription.Equals(outputDescription)) return; - - _maskPipeline?.Dispose(); - _maskDebugPipeline?.Dispose(); - _outlinePipeline?.Dispose(); - _pipelineOutputDescription = outputDescription; - - // No GPU face culling in either pass: source mesh winding isn't trustworthy for these - // assets (see the class comment above), same reason the main renderer uses CULL_NONE. - var rasterizerState = new RasterizerStateDescription( - FaceCullMode.None, PolygonFillMode.Solid, FrontFace.Clockwise, - depthClipEnabled: true, scissorTestEnabled: false); - - var stampStencil = new StencilBehaviorDescription(StencilOperation.Keep, StencilOperation.Replace, StencilOperation.Keep, ComparisonKind.Always); - var maskDepthStencil = new DepthStencilStateDescription - { - DepthTestEnabled = true, - DepthWriteEnabled = false, - DepthComparison = ComparisonKind.LessEqual, - StencilTestEnabled = true, - StencilFront = stampStencil, - StencilBack = stampStencil, - StencilReadMask = 0xFF, - StencilWriteMask = 0xFF, - StencilReference = 1, - }; - - var maskBlend = new BlendStateDescription(RgbaFloat.WHITE, new BlendAttachmentDescription - { - BlendEnabled = false, - ColorWriteMask = ColorWriteMask.None, - SourceColorFactor = BlendFactor.One, - DestinationColorFactor = BlendFactor.Zero, - ColorFunction = BlendFunction.Add, - SourceAlphaFactor = BlendFactor.One, - DestinationAlphaFactor = BlendFactor.Zero, - AlphaFunction = BlendFunction.Add, - }); - - var maskPipelineDescription = new GraphicsPipelineDescription( - maskBlend, maskDepthStencil, rasterizerState, PrimitiveTopology.TriangleList, - new ShaderSetDescription([_vertexLayout], _shaders), [_layout], outputDescription, ResourceBindingModel.Default); - _maskPipeline = _gd.ResourceFactory.CreateGraphicsPipeline(ref maskPipelineDescription); - - // Same as the mask pipeline but with normal color writes — diagnostic only, lets - // DrawOutline's debugVisualizeMask flag show exactly what pass 1 actually covers, - // instead of guessing whether a bad result is a masking failure or a stencil-exclusion - // failure. - var maskDebugPipelineDescription = new GraphicsPipelineDescription( - BlendStateDescription.SINGLE_DISABLED, maskDepthStencil, rasterizerState, PrimitiveTopology.TriangleList, - new ShaderSetDescription([_vertexLayout], _shaders), [_layout], outputDescription, ResourceBindingModel.Default); - _maskDebugPipeline = _gd.ResourceFactory.CreateGraphicsPipeline(ref maskDebugPipelineDescription); - - var rimStencil = new StencilBehaviorDescription(StencilOperation.Keep, StencilOperation.Keep, StencilOperation.Keep, ComparisonKind.NotEqual); - var outlineDepthStencil = new DepthStencilStateDescription - { - DepthTestEnabled = true, - DepthWriteEnabled = false, - DepthComparison = ComparisonKind.LessEqual, - StencilTestEnabled = true, - StencilFront = rimStencil, - StencilBack = rimStencil, - StencilReadMask = 0xFF, - StencilWriteMask = 0x00, - StencilReference = 1, - }; - - var outlinePipelineDescription = new GraphicsPipelineDescription( - BlendStateDescription.SINGLE_DISABLED, outlineDepthStencil, rasterizerState, PrimitiveTopology.TriangleList, - new ShaderSetDescription([_vertexLayout], _shaders), [_layout], outputDescription, ResourceBindingModel.Default); - _outlinePipeline = _gd.ResourceFactory.CreateGraphicsPipeline(ref outlinePipelineDescription); - } - - /// Draws a solid-color outline around the given meshes. Call after the main opaque pass has written depth, on the same command list/framebuffer. - /// Diagnostic override: skips the outline pass and draws pass 1's mask directly in solid color, so it's visible whether the mask itself covers the object correctly instead of guessing from the (potentially broken) final composite. - public void DrawOutline(CommandList commandList, OutputDescription outputDescription, Matrix4x4 viewProjection, IEnumerable<(IMesh mesh, Matrix4x4 world)> entries, Vector4 color, float thickness = 0.006f, bool debugVisualizeMask = false) - { - EnsurePipelines(outputDescription); - - var meshes = entries.Where(e => e.mesh.IndexCount > 0).ToList(); - if (meshes.Count == 0) return; - - Span uniformData = stackalloc byte[160]; - MemoryMarshal.Write(uniformData[128..], in color); - - commandList.SetPipeline(debugVisualizeMask ? _maskDebugPipeline : _maskPipeline); - MemoryMarshal.Write(uniformData[144..], new Vector4(0f, 0f, 0f, 0f)); - DrawMeshes(commandList, meshes, viewProjection, uniformData); - - if (debugVisualizeMask) return; - - commandList.SetPipeline(_outlinePipeline); - MemoryMarshal.Write(uniformData[144..], new Vector4(thickness, 0f, 0f, 0f)); - DrawMeshes(commandList, meshes, viewProjection, uniformData); - } - - private void DrawMeshes(CommandList commandList, List<(IMesh mesh, Matrix4x4 world)> meshes, Matrix4x4 viewProjection, Span uniformData) - { - MemoryMarshal.Write(uniformData, in viewProjection); - foreach (var (mesh, world) in meshes) - { - MemoryMarshal.Write(uniformData[64..], in world); - - commandList.UpdateBuffer(_uniformBuffer, 0, uniformData.ToArray()); - commandList.SetGraphicsResourceSet(0, _resourceSet); - commandList.SetVertexBuffer(0, mesh.VertexBuffer); - commandList.SetIndexBuffer(mesh.IndexBuffer, IndexFormat.UInt32); - commandList.DrawIndexed(mesh.IndexCount); - } - } - - public void Dispose() - { - _maskPipeline?.Dispose(); - _maskDebugPipeline?.Dispose(); - _outlinePipeline?.Dispose(); - _resourceSet.Dispose(); - _uniformBuffer.Dispose(); - _layout.Dispose(); - foreach (var shader in _shaders) shader.Dispose(); - GC.SuppressFinalize(this); - } -} diff --git a/ReLunacy.Engine/Rendering/Shaders/BillboardModelShaderSource.cs b/ReLunacy.Engine/Rendering/Shaders/BillboardModelShaderSource.cs new file mode 100644 index 0000000..effa273 --- /dev/null +++ b/ReLunacy.Engine/Rendering/Shaders/BillboardModelShaderSource.cs @@ -0,0 +1,37 @@ +namespace ReLunacy.Engine.Rendering.Shaders; + +// Camera-facing sprite cards for foliage. Same buffer/texture layout as +// VertexAlphaModelShaderSource and Bliss's default_model (MatrixBuffer@0 vertex, +// TransformBuffer@1 vertex, MaterialBuffer@2 fragment, Albedo@3), so it is a drop-in Effect swap +// with no pipeline differences - see AssetManager.BuildBillboardModelEffect. +// +// The billboard is built the way the game's own foliage vertex program builds it: transform the +// sprite's ANCHOR normally, then add the corner offset in a plane that faces the viewer. Doing the +// add after the view matrix is what makes the card face the camera, because view space already has +// the camera at the origin looking down -Z, so its X/Y axes are the screen axes by construction. +// +// vTexCoords2 carries the 2D corner offset (see EntityFoliage - it is the only free per-vertex +// vec2 in Vertex3D, and foliage has no lightmap UV to compete for it). vPosition carries the +// anchor, NOT the final corner position, which is why every four vertices of a card share the same +// vPosition and differ only in vTexCoords2. +// +// The instance's scale is recovered from the model matrix rather than being lost - see the vertex +// body. Skipping that made every card about 5.9x too large on metropolis, whose foliage placements +// scale by a median of 0.17. +// +// KNOWN DIFFERENCES FROM THE GAME, both from vertex constants that are not in the level files: +// - the game multiplies the corner offset by vc[41].x as well, a per-draw scale we do not have, +// so card size is right only up to that constant; +// - the per-sprite rotation comes from an indexed lookup, vc[42 + a0] / vc[43 + a0], selected by +// the two packed bytes on each sprite (see Loading.Vertices.FoliageSpriteAnchor). Those +// constants also carry a Z component, so the game can tilt a card out of the screen plane. +// Cards here stay axis-aligned to the screen and untilted. +// +// Shader source lives in Shaders/billboardv.glsl / billboardf.glsl (see ShaderAsset). ASCII ONLY in +// those files, comments included - a non-ASCII byte makes the runtime shader compile fail with a +// misleading "unexpected end of file" error. +internal static class BillboardModelShaderSource +{ + public static string Vertex => ShaderAsset.Load("billboardv.glsl"); + public static string Fragment => ShaderAsset.Load("billboardf.glsl"); +} diff --git a/ReLunacy.Engine/Rendering/Shaders/LitModelShaderSource.cs b/ReLunacy.Engine/Rendering/Shaders/LitModelShaderSource.cs index 2414b9e..9dc2d78 100644 --- a/ReLunacy.Engine/Rendering/Shaders/LitModelShaderSource.cs +++ b/ReLunacy.Engine/Rendering/Shaders/LitModelShaderSource.cs @@ -1,17 +1,19 @@ namespace ReLunacy.Engine.Rendering.Shaders; -// First real lighting pass for the live renderer (everything else in this engine is unlit — see +// First real lighting pass for the live renderer (everything else in this engine is unlit - see // GlobalResource.DefaultModelEffect / VertexAlphaModelShaderSource). The shading MODEL follows -// Insomniac's own "Prelighting" / "Pre-lighting in Resistance 2" (Mark Lee) decks — the same +// Insomniac's own "Prelighting" / "Pre-lighting in Resistance 2" (Mark Lee) decks - the same // tech family Tools of Destruction shipped on: diffuse = albedo * sum(l_col * l_att * (n.l)), // specular = gloss * sum((l_dir . Refl(v,n))^p * l_col * l_att) with a PHONG reflection vector // (the decks' Refl(v,n) form), combined as C = mp * P, all evaluated in linear space with sRGB // decode/encode at the edges. What is deliberately NOT ported from those decks is the screen-space // deferred ARCHITECTURE (depth/normal pre-pass, light-accumulation buffers, stencil light // volumes, sun-shadow min-blend buffers): that machinery only pays for itself with many dynamic -// lights and shadow casters, and this editor has exactly one configurable sun and no parsed light -// data (none exists in the level files) — a forward evaluation of the identical equations produces -// the identical shading. +// lights and shadow casters, and at the time this was written the editor had exactly one configurable +// sun and no parsed light data - a forward evaluation of the identical equations produces the +// identical shading. (Analytic light data has SINCE been found, in main.dat section 0x8b00; see the +// note further down. The forward-vs-deferred reasoning is unaffected - it is still one rig, not many +// dynamic lights.) // // The MATERIAL half of this shader is a direct port of the game's own fragment shader, dumped from // RPCS3 via RenderDoc and traced in fragment_shader_annotated.glsl. Ported 1:1 from it: parallax @@ -20,8 +22,8 @@ namespace ReLunacy.Engine.Rendering.Shaders; // partial derivatives used verbatim as (dx, dy, 1) with no sign flip; the detail map's R,G // composed into those derivatives by plain ADDITION (the property that motivates the encoding), // its A added to specular, the whole fetch gated by the expensive alpha (its B channel feeds -// albedo in the game but is deliberately not applied here — see the detail-map notes); per-MATERIAL specular power; and emissive folded inside the light term so it multiplies -// by albedo. Those channels are pure INTENSITIES, never tints — the only colour a surface has is +// albedo in the game but is deliberately not applied here - see the detail-map notes); per-MATERIAL specular power; and emissive folded inside the light term so it multiplies +// by albedo. Those channels are pure INTENSITIES, never tints - the only colour a surface has is // its albedo. // // BAKED LIGHTING is ported but DISABLED by default - see AssetManager.EnableBakedLighting. @@ -45,548 +47,33 @@ namespace ReLunacy.Engine.Rendering.Shaders; // Where a bake exists it REPLACES the dynamic sun rather than adding to it - they are two answers // to the same question. maps[6].value is the flag that selects between them. // -// Still absent: the HDR environment cubemap's contents (zone 0x72c1 gives only the lookup), the -// per-vertex distance/bump fade, the dedicated detail UV set, the specular-tint constants, fog, -// and the glow-mask alpha output. There is NO analytic light data (ambient colour, sun direction) -// anywhere in the level files — neither InsomniaToolset nor Ymir found any, because the game's -// lighting IS the baked textures. The dynamic sun below is therefore not a stand-in for data we -// have yet to locate; it is a substitute for a different technique, used only where no bake exists. +// The HDR environment cubemap (old-engine section 0x5920) is now decoded and sampled for +// reflections - see the ENVIRONMENT FILL section in the fragment source and CubemapReader. +// Still absent: the per-vertex distance/bump fade, the dedicated detail UV set, the specular-tint +// constants, fog, and the glow-mask alpha output. +// +// SUPERSEDED, and worth reading as a lesson in how to write these notes: this paragraph used to state +// flatly that there is NO analytic light data anywhere in the level files, on the grounds that neither +// InsomniaToolset nor Ymir had found any. That was a "we have not found it YET" - the strongest claim +// the evidence supported was "nobody has located it", which is not the same as "it does not exist". +// It DOES exist: main.dat section 0x8b00 carries a per-level ambient colour plus two directional +// lights, now decoded (LightingEnvironmentReader) and fed to the shader as uEnvAmbient / +// uEnvDirection0/1. So the fallback used where no bake exists is the game's OWN rig, not a fabricated +// editor sun - which also makes it a live question whether that ambient should reach baked surfaces +// too, rather than being replaced by the bake outright. See dev/ShaderExtraction.md. // // NOTE: GltfExporter.ApplyExpensiveChannels still splits the expensive texture under the older -// R=spec/G=metallic/B=emissive reading. That is now demonstrably wrong output — G is the parallax -// height, not metallic — and needs correcting separately. +// R=spec/G=metallic/B=emissive reading. That is now demonstrably wrong output - G is the parallax +// height, not metallic - and needs correcting separately. // Always forwards vColor.a like -// VertexAlphaModelShaderSource does — see AssetManager.SelectEffect, which uses this one effect +// VertexAlphaModelShaderSource does - see AssetManager.SelectEffect, which uses this one effect // for every material regardless of UsesVertexAlphaCandidate once lighting is enabled, since // vColor.a is already 1.0 for non-vertex-alpha materials (ConvertGeometryToVertices) so folding // both into one shader is a safe simplification rather than needing 4 effect variants. +// +// Shader source lives in Shaders/litmodelv.glsl / litmodelf.glsl (see ShaderAsset). internal static class LitModelShaderSource { - public const string Vertex = """ - #version 450 - - layout(std140, set = 0, binding = 0) uniform MatrixBuffer { - mat4x4 uProjection; - mat4x4 uView; - }; - - layout(std140, set = 1, binding = 0) uniform TransformBuffer { - mat4x4 uTransformation; - }; - - layout (location = 0) in vec3 vPosition; - layout (location = 1) in vec2 vTexCoords; - layout (location = 2) in vec2 vTexCoords2; - layout (location = 3) in vec3 vNormal; - layout (location = 4) in vec4 vTangent; - layout (location = 5) in vec4 vColor; - - layout (location = 0) out vec2 fTexCoords; - layout (location = 1) out vec4 fColor; - layout (location = 2) out vec3 fWorldNormal; - layout (location = 3) out vec3 fWorldTangent; - layout (location = 4) out float fTangentHandedness; - layout (location = 5) out vec3 fWorldPos; - // The LIGHTMAP UV set. Proven by the captured vertex program: it writes - // tc0 = (attr1.xy, attr2.xy), and the fragment program samples the baked light colour and - // direction at tc0.zw - i.e. vertex attribute 2, a second UV pair, NOT the base UV. - layout (location = 6) out vec2 fTexCoords2; - - void main() { - fTexCoords = vTexCoords; - fTexCoords2 = vTexCoords2; - fColor = vColor; - - // A proper inverse-transpose normal matrix, not just the upper 3x3 of the world - // transform - the naive matrix only happens to give the right answer for the special - // case of a pure +-1-magnitude axis flip with no real scaling; this engine's actual - // per-asset/per-instance Scale is an arbitrary float (moby.Scale, instance placement - // scale, etc.), and for any OTHER scale magnitude - including negative ones used to - // bake in a mirrored placement, which this game does often instead of an actual - // rotation - the naive transform distorts the normal instead of just mirroring it, - // which is what was reading as "shading looks inverted" on those instances. - mat3 modelMatrix3 = mat3(uTransformation); - mat3 normalMatrix = transpose(inverse(modelMatrix3)); - fWorldNormal = normalize(normalMatrix * vNormal); - fWorldTangent = normalize(normalMatrix * vTangent.xyz); - - // Separately from the normal matrix above: a mirrored (negative-determinant) instance - // transform also flips the surface's effective winding, so the TANGENT-SPACE - // reconstruction in the fragment shader needs its bitangent handedness flipped to - // match, or per-pixel normal-map detail comes out inverted even once the plain - // per-vertex normal above is correct. - fTangentHandedness = vTangent.w * sign(determinant(modelMatrix3)); - - mat4x4 transformation = uTransformation; - vec4 v4Pos = vec4(vPosition, 1.0F); - vec4 worldPos = transformation * v4Pos; - fWorldPos = worldPos.xyz; - gl_Position = uProjection * uView * worldPos; - } - """; - - public const string Fragment = """ - #version 450 - - #define MAX_MAPS_COUNT 8 - - struct MaterialMap { - vec4 color; - float value; - }; - - layout(std140, set = 2, binding = 0) uniform MaterialBuffer { - int renderMode; - MaterialMap maps[MAX_MAPS_COUNT]; - }; - - // Set numbering is dictated by Bliss's pipeline layout construction, NOT free choice: - // every uniform buffer must come first in a contiguous run from 0, then every texture. - // See AssetManager.BuildLitModelEffect for the full explanation and the GPU fault that - // interleaving them caused. - layout(std140, set = 3, binding = 0) uniform LightBuffer { - vec3 uLightDirection; - float uAmbient; - vec3 uLightColor; - float uSpecularPower; - vec3 uCameraPosition; - float _reserved0; - vec3 uEnvironmentColour; - float uEnvironmentIntensity; - vec2 uLightmapUVScale; - vec2 uLightmapUVOffset; - float uBakedLightScale; - float uBakedBumpFade; - float uBakedDebugView; - float _reserved1; - vec2 uLightmapUVPivot; - float uLightmapUVRotation; - float _reserved2; - }; - - layout (set = 4, binding = 0) uniform texture2D fAlbedo; - layout (set = 4, binding = 1) uniform sampler fAlbedoSampler; - - layout (set = 5, binding = 0) uniform texture2D fNormal; - layout (set = 5, binding = 1) uniform sampler fNormalSampler; - - layout (set = 6, binding = 0) uniform texture2D fProperties; - layout (set = 6, binding = 1) uniform sampler fPropertiesSampler; - - layout (set = 7, binding = 0) uniform texture2D fDetail; - layout (set = 7, binding = 1) uniform sampler fDetailSampler; - - // The game's own baked lighting, from zone sections 0x5400 / 0x5410. Per-INSTANCE: the - // material cache is keyed on the lightmap index so each placement gets its own pair. - layout (set = 8, binding = 0) uniform texture2D fLightColour; - layout (set = 8, binding = 1) uniform sampler fLightColourSampler; - - layout (set = 9, binding = 0) uniform texture2D fLightDir; - layout (set = 9, binding = 1) uniform sampler fLightDirSampler; - - layout (location = 0) in vec2 fTexCoords; - layout (location = 1) in vec4 fColor; - layout (location = 2) in vec3 fWorldNormal; - layout (location = 3) in vec3 fWorldTangent; - layout (location = 4) in float fTangentHandedness; - layout (location = 5) in vec3 fWorldPos; - layout (location = 6) in vec2 fTexCoords2; - - layout (location = 0) out vec4 fFragColor; - - void main() { - vec3 n = normalize(fWorldNormal); - vec3 t = normalize(fWorldTangent - n * dot(fWorldTangent, n)); - vec3 b = cross(n, t) * fTangentHandedness; - mat3 tbn = mat3(t, b, n); - - vec3 viewDir = normalize(uCameraPosition - fWorldPos); - - // Single-tap parallax offset from the expensive map's G channel, confirmed as the - // parallax HEIGHTMAP by the game's own captured fragment shader (see - // fragment_shader_annotated.glsl). This reproduces that shader's exact form: - // - // height = heightRaw * scale + bias (both per-material constants) - // uv = uv + viewDirTS.xy * height (ADDED, not subtracted) - // - // Two consequences worth not re-deriving later. First, there is no fixed sign - // convention to discover: a negative scale flips the offset direction, so which way - // relief appears to move is DATA, not a bug to fix in this math. Second, height is - // scale/bias remapped rather than scaled by a bare multiplier, so a bias of 0 is what - // gives "0 height -> no offset" - it is not inherent to the encoding. - // maps[3].value / maps[4].value are those two constants, live-tunable per material - // from the ShaderBrowser (see AssetManager.SetParallax) precisely so candidate float - // pairs found in the raw shader-metadata hex dump can be tried verbatim - nothing - // scales them further here, which is the point. - // Offset-LIMITED parallax (no division by viewDirTS.z), which the game shader also - // does NOT do: the classic divide amplifies the UV shift toward infinity at grazing - // view angles, which with a single tap shreds the albedo/normal sampling into blocky - // swimming artifacts (confirmed live: read as "pixelated artifacts over the albedo"). - vec3 viewDirTS = transpose(tbn) * viewDir; - float heightRaw = texture(sampler2D(fProperties, fPropertiesSampler), fTexCoords).g; - float height = heightRaw * maps[3].value + maps[4].value; - vec2 texCoords = fTexCoords + viewDirTS.xy * height; - - vec4 texelColor = texture(sampler2D(fAlbedo, fAlbedoSampler), texCoords); - - switch (renderMode) { - case 0: - texelColor.a = 1.0F; - break; - case 1: - // maps[0].value carries the material's own alphaClip threshold from the - // game's shader metadata (see AssetManager.GetOrBuildMaterial), replacing a - // hardcoded 0.99: that constant was invisible under point sampling (alpha is - // mostly pure 0/255) but under bilinear filtering every softened edge texel - // falls below 0.99 and gets discarded, eroding cutout foliage/decals into - // sparse pixel speckle (confirmed live). - if (texelColor.a < maps[0].value) { - discard; - } - break; - } - - // This game's normal maps store partial derivatives, not a standard tangent-space - // (nx,ny,nz) encoding - B is always constant/unused, R unused. Reconstruction is - // simply normalize(vec3(dx, dy, 1)) in tangent space, taken straight from the game's - // own captured fragment shader, which does no sign flip at all: it uses the sampled - // texel's .xyz directly as (dx, dy, 1) and only ever ADDS the detail map's derivative - // to .xy (see fragment_shader_annotated.glsl, "NORMAL MAP" section). - // This previously negated both derivatives - normalize(vec3(-dx, -dy, 1)) - reasoning - // from the classic height-gradient convention where the map stores dh/du and the - // normal needs -dh/du. That double-negates here, because the stored value is already - // the negated ratio, and it inverted the perceived relief on every normal-mapped - // surface. It only became visible once lighting actually worked; before the descriptor - // set-numbering fix (see AssetManager.BuildLitModelEffect) the light direction and - // camera position were garbage, so nothing about the shading was trustworthy. - // Channel assignment is dx=Alpha, dy=Green - confirmed against Negotiator/TextureEditor - // (a separate, working reverse-engineering tool for this exact game's formats - - // TextureHelper.BitmapFromDDS's DXT5 normal-map path reads p.A for dx and p.G for dy), - // not G=dx/A=dy as originally guessed here. The captured shader can't corroborate the - // channel order: RSX texture remap is folded into the Vulkan image-view swizzle and - // never appears in the decompiled body. See TextureUtils.ReconstructNormalMap for the - // export-side equivalent, which needs the same convention. - vec4 normalSample = texture(sampler2D(fNormal, fNormalSampler), texCoords); - float dx = normalSample.a * 2.0F - 1.0F; - float dy = normalSample.g * 2.0F - 1.0F; - - // The expensive map's channels are pure INTENSITIES, never color/tint sources (the - // only color a surface has is its albedo - emissive glows in the albedo's own color, - // specular flashes in the LIGHT's color). Layout confirmed against the game's own - // captured shader: R = specular intensity, G = parallax height (sampled above, at the - // un-offset UV), B = emissive/incandescence intensity, A = DETAIL MAP MASK. - // A is NOT roughness - that reading is retracted. In the captured shader A does - // exactly one thing, gate the detail-map fetch, and never reaches a specular exponent; - // Insomniac's own slide lists "detail map mask" as a named material input. Specular - // power is per-MATERIAL there (the cubemap LOD constant), never per-texel. - // Every earlier variant that promoted a channel into a tint (constant-white specTint, - // albedo-tinted env fill, spec-channel-tinted fill) produced a scene-wide artifact in - // live testing (white filter / pitch-black metals) - keep this a pure intensity model. - vec4 propsSample = texture(sampler2D(fProperties, fPropertiesSampler), texCoords); - float specIntensity = propsSample.r; - float emissiveIntensity = propsSample.b; - // The detail mask is the expensive map's alpha - but only when that texture actually - // HAS an alpha channel. DXT1, R5G6B5, R8, BC4 and BC5 don't; block decoders synthesise - // an opaque 255 there, which is not authored data. maps[1].value flags which case this - // material is in (see AssetManager.GetOrBuildMaterial): 1 = real alpha, sample it; - // 0 = none, so fall back to a constant fully-on mask. Fully-on rather than fully-off - // because that is what the original hardware produced too - RSX also returns 1.0 - // sampling alpha from a DXT1 texture - so an unauthored mask simply doesn't attenuate. - float detailMask = mix(1.0F, propsSample.a, maps[1].value); - - // DETAIL MAP. Layout, read straight off the captured shader's consumers: - // R,G -> a partial-derivative perturbation ADDED to the normal map's derivatives - // B -> additive albedo brightness (a SCALAR lift, never a hue) - // A -> additive specular intensity - // B feeding albedo is the surprising one, so here is the register trace that proves it - // (fragment_shader.glsl; h1 is the masked detail texel): - // L367 h6.xy = (h1.zwzz * fc[5].zwzz).xy -> h6.x = detail.B * fc[5].z - // h6.y = detail.A * fc[5].w - // L374 h3.x = (h6.yyyy + h3).x -> specular += detail.A * fc[5].w - // L383 fma(h0.xxxx, h1, h6.xxxx) -> albedo = albedoScale * base + h6.x - // h6.xxxx broadcasts one scalar across RGB, hence "lift, not hue". - // WHY IT BLOWS OUT HERE AND NOT IN THE GAME: the game attenuates this by three factors - // we cannot source - detailAlbedoStrength (fc[5].z), detailMaskStrength (fc[3].y) and - // a per-vertex detail fade (tc6.z) - so our detailWeight is systematically the largest - // it can possibly be. On top of that the mask itself reads 1.0 on every DXT1 expensive - // map, whose alpha decodes as 255. At strength 1 that is a full +1.0 on linear albedo, - // i.e. white. The operation is right; the magnitude has no evidence behind it, which is - // exactly why the strengths default to 0 and are sliders. - // The whole fetch is scaled by the expensive map's alpha, so a material with no detail - // mask pulls in nothing. Plain ADDITION of the derivatives is the entire reason this - // encoding is used - Insomniac's Prelighting deck calls it out explicitly; two - // derivative maps compose without any reorientation or blend, which is exactly what - // makes a cheap high-frequency detail layer viable. - // maps[5].value is the normal strength and maps[5].color.r the specular strength - - // both live-tunable per material from the ShaderBrowser (AssetManager.SetDetailStrengths). - // The specular one rides a colour channel because slots 6 and 7 now carry the baked - // lighting textures; see AssetManager's SLOT BUDGET note. - // The detail map's ALBEDO contribution (its B channel) is not applied at all - see the - // channel notes above for the trace proving it exists and why it stays off. - // TILING. Detail maps are authored small and meant to tile at a higher frequency than - // the base map. In the game that tiling is NOT a fragment constant: the detail UV - // arrives pre-tiled in a vertex interpolant, traced from - // `uvDetail = parallaxOffset * fc[2].z + tc6.xy` - tc6.xy is already the tiled detail - // UV, built by a vertex program that wasn't captured. So the multiplier lives upstream, - // either in that vertex shader or in a ShaderMetadata field not identified yet. - // maps[2].value stands in for it, live-tunable per material from the ShaderBrowser, - // defaulting to 1 (same frequency as the base map). - // Useful when hunting it: the BASE map DOES get a fragment-constant tiling in the game, - // fc[1].xy, applied as uvMain = uvParallax * fc[1].xy. This shader doesn't reproduce - // that. All 8 MaterialMap slots AND their value fields are now spoken for, so adding it - // needs a real per-material uniform buffer rather than another map slot. - // Also not reproduced: the game gives the detail UV its own scaled share of the - // parallax shift (the fc[2].z above). That constant isn't identified, so detail samples - // at the UN-offset base UV - exactly what fc[2].z = 0 would give. - // Order matters: R,G are SIGNED derivatives stored biased into an unsigned texture, so - // they must be decoded to their signed range BEFORE the mask is applied. Masking the - // raw texel first would turn a fully-masked-out pixel (detail = 0) into a decoded - // derivative of -1, i.e. a full-strength perturbation exactly where the material asked - // for none. The game gets this right for free: RSX's fixed-function signed expansion - // happens at fetch, before its detailWeight multiply. - vec2 detailCoords = fTexCoords * maps[2].value; - vec4 detailSample = texture(sampler2D(fDetail, fDetailSampler), detailCoords); - vec2 detailDerivative = vec2(detailSample.r * 2.0F - 1.0F, detailSample.g * 2.0F - 1.0F) * (detailMask * maps[5].value); - float detailSpecAdd = detailSample.a * detailMask * maps[5].color.r; - - // Derivative composition by addition - see above. Only then normalize. - vec2 derivativeSum = vec2(dx, dy) + detailDerivative; - vec3 tangentNormal = normalize(vec3(derivativeSum, 1.0F)); - vec3 worldNormal = normalize(tbn * tangentNormal); - - specIntensity = specIntensity + detailSpecAdd; - - // All lighting happens in LINEAR space - the game's own pipeline lit in linear and the - // source albedo textures are sRGB-authored, so lighting the raw gamma values (what this - // shader originally did) double-darkens every midtone and crushes shadowed areas to - // black. Approximate 2.2 decode here, matching encode at the end. The detail map's - // albedo lift is added AFTER linearization, matching the captured shader, where the - // detail texel and the base texel have both already been through the same fixed - // function conversion before they meet. - vec3 albedo = pow(texelColor.rgb * maps[0].color.rgb * fColor.rgb, vec3(2.2F)); - - // Insomniac's own factoring (Prelighting / GDC09 decks), evaluated forward for a - // single directional sun with l_att = 1: - // P_diffuse = l_col * l_att * (n . l) - // P_specular = (l_dir . Refl(v, n))^p * l_col * l_att (PHONG reflection vector, - // per the decks - not a Blinn half-vector) - // C = mp * P: final = albedo * diffuseLight + specIntensity * specLight (+ emissive) - vec3 lightDir = normalize(uLightDirection); - vec3 reflDir = reflect(-viewDir, worldNormal); - - float nDotL = max(dot(worldNormal, lightDir), 0.0F); - vec3 diffuseAccum = uLightColor * nDotL; - - // Specular power is PER-MATERIAL, not per-texel. The captured shader takes it from a - // material constant (the environment cubemap's LOD), and Insomniac's own deck says - // "per material specular power" in as many words. A previous per-pixel version scaled - // this exponent by the expensive map's alpha as a roughness - that channel is the - // detail-map mask, so the whole idea is retracted. Removed with it: an - // energy-conservation term, (specPower + 2) / (uSpecularPower + 2), which existed only - // to suppress the artifact the alpha-as-roughness reading caused (DXT1 expensive maps - // decode alpha as 255 = "fully rough" = a power-1 lobe at full strength, reading as a - // view-independent white film). With the cause gone the correction would only dim - // specular for no reason. - float specPower = max(uSpecularPower, 1.0F); - float specAccum = pow(max(dot(lightDir, reflDir), 0.0F), specPower); - - // The decks' baked-lighting/lightmap input has no equivalent here (no light data in - // the level files at all). A FLAT ambient stand-in made every face turned away from - // the sun an identical dead value (confirmed live: crates/props in shadowed - // orientations read "unshaded" next to sunlit neighbors), so this is a two-tone - // hemisphere instead: full ambient from above fading to half toward straight down - - // the cheapest stand-in that keeps shadowed geometry readable and directional. - vec3 ambientLight = uAmbient * mix(vec3(0.5F), vec3(1.0F), worldNormal.y * 0.5F + 0.5F); - - // Composite in the captured shader's own factoring: - // colour = albedo * (light * diffuseTerm + emissive) + specular - // Emissive belongs INSIDE the light term, so it is multiplied by albedo - which is why - // a surface glows in its own albedo colour and expensive.B stays a pure intensity. - // Here the baked directional lightmap the game multiplies in is replaced by the - // dynamic sun plus the hemisphere ambient below; the grouping is otherwise identical. - // ---- BAKED LIGHTING (the game's own) -------------------------------------------- - // maps[6].value is 1 only when this material actually has a bake; the textures are - // always bound (an unbound declared set is undefined behaviour) but hold inert - // fallbacks otherwise, so this flag is what keeps them from reading as a real light. - // Sampled at fTexCoords2, the LIGHTMAP UV SET - this is settled, not inferred. The - // captured vertex program builds tc0 = (attr1.xy, attr2.xy) and the fragment program - // reads the bakes at tc0.zw - BOTH of them, tex4 (0x5400) and tex14 (0x5410) - so the - // lightmap UV is vertex attribute 2. - // The attribute layout is no longer inferred from the shader body either: the captured - // DrawParametersBuffer (set 0, binding 2) spells it out, and 2498 draws in that frame - // carry this exact shape - stride 24, one non-volatile stream, swap_bytes set: - // attr0 +0 SINT16 x4 position (.w feeding the per-vertex albedo scale) - // attr1 +8 SFLOAT16 x2 UVs - // attr2 +12 SFLOAT16 x2 UVs2 <- the lightmap UV, and it is a HALF FLOAT - // attr3 +16 CMP 11:11:10 normal - // attr4 +20 CMP 11:11:10 tangent - // That is UFragVertex field for field, so the captured draw is TERRAIN. It also settles - // the normal/tangent order independently: the vertex program's tangent-space view - // vector comes out as (dot(v, attr4), dot(v, bitangent), dot(v, attr3)), i.e. the - // (T, B, N) rows, putting the normal at +16 and the tangent at +20 - which is what this - // loader already assumed, now confirmed rather than guessed. The bitangent is - // cross(attr3, attr4) with its handedness taken from sign(position.w). - // An earlier version sampled at the base UV, reasoning that a unique bake per instance - // implies the asset's own UV works. That was wrong twice over: a unique bake still - // needs a unique UNWRAP, and the extracted tex4 is plainly an atlas of unwrapped - // islands. Base UVs tile a detail texture across a surface, so the lightmap repeated - // many times per mesh - the blotchy black patching. - float hasBaked = maps[6].value; - // Live UV transform - a research control, identity by default. See LightData.LightmapUVScale. - // Rotation first, about uLightmapUVPivot, THEN scale and offset - so the pivot stays a - // point in the source atlas rather than drifting whenever the scale changes. - // Rotating about the atlas centre would be useless here: UVs2 are atlas coordinates, so - // that sweeps an island across unrelated islands instead of spinning it in place. Aim - // the pivot at the island being inspected (the UFrag Inspector can snap it there). - vec2 uvCentred = fTexCoords2 - uLightmapUVPivot; - float uvSin = sin(radians(uLightmapUVRotation)); - float uvCos = cos(radians(uLightmapUVRotation)); - vec2 uvRotated = vec2(uvCentred.x * uvCos - uvCentred.y * uvSin, - uvCentred.x * uvSin + uvCentred.y * uvCos) + uLightmapUVPivot; - vec2 bakedUV = uvRotated * uLightmapUVScale + uLightmapUVOffset; - vec4 bakedColour = texture(sampler2D(fLightColour, fLightColourSampler), bakedUV); - vec4 bakedDirSample = texture(sampler2D(fLightDir, fLightDirSampler), bakedUV); - - // Tangent-space light direction. CHANNEL ORDER IS NOT (r,g,b)->(x,y,z): the - // normal-ward component lives in GREEN. Measured across 400 DXT1 directional maps in - // metropolis - G never drops below ~126 (0% of texels under 128) while R and B are both - // centred exactly on 128 with symmetric spread (34% / 30% below). That is a signed - // lateral pair plus one always-positive axis, and DXT1 gives green 6 bits against 5 for - // red and blue, so the dominant component belongs there for precision. - // The dump can't show this directly: RSX applies a per-texture channel REMAP that - // RPCS3 folds into the Vulkan image-view swizzle, so the decompiled body just reads - // .xyz off an already-swizzled texel (see [INFERRED] (b) in the annotated capture). - // Reading B as z and expanding it made ~30% of texels come out with a NEGATIVE z, which - // clamps nDotL to zero - the black artifacts. - // G is NOT signed-expanded, only R and B are: G's floor of ~126 maps to ~0 under - // expansion, which would reintroduce division-by-almost-zero right back. Left raw it - // stays comfortably positive, so the renormalisation below is always well conditioned. - // NO signed expansion - the components are stored RAW. Measured over 4800 texels from - // 300 directional maps: taking (r,g,b) as-is gives a mean vector length of 0.955 with - // a standard deviation of 0.041, and half the texels land within 5% of exactly 1.0 - // (the shortfall is DXT1 quantisation). Every signed-expansion variant tried - - // including (r,g,b)*2-1 and the r/b-signed-with-g-raw form previously used here - - // produces lengths of 0.28 to 0.64 and NOTHING near unity. A unit-length field is what - // a direction is; nothing else in this format has a reason to be normalised. - // Channel ORDER is the remaining unknown: permuting components can't change a length, - // so this test cannot distinguish them. G is taken as the normal-ward axis because it - // is systematically the largest (mean 158 vs 127 for r and b), which is what you expect - // when light usually arrives from above the surface. Getting x/y backwards would shear - // the lighting along a tangent axis, not produce artifacts. - vec3 bakedLightDirTS = vec3(bakedDirSample.r, bakedDirSample.b, bakedDirSample.g); - float bakedLen = length(bakedLightDirTS); - bakedLightDirTS = bakedLen > 0.0F ? bakedLightDirTS / bakedLen : vec3(0.0F, 0.0F, 1.0F); - - // N.L against the TANGENT-space shading normal, matching the captured shader, which - // never leaves tangent space for this term. - // BUMP FADE stand-in. The game scales the normal's derivatives by vViewTS.w - a - // clamped 0..1 per-vertex distance factor - BEFORE normalising, so normals flatten - // toward (0,0,1) with distance. We have no source for that value, and applying the - // derivatives at full strength is the worst case for this term: the light directions - // are all-positive, normalising to roughly (0.52, 0.52, 0.65), so - // dot ~= 0.52*dx + 0.52*dy + 0.65 - // goes NEGATIVE whenever dx + dy < -1.25, and the clamp below then drives the pixel to - // exactly zero no matter how bright the lightmap texel is. That is what punches pitch - // black holes through otherwise correct baked colour. - // Softening the derivatives here restores the property that actually matters: with a - // flat normal the / lightDirTS.z renormalisation reproduces the baked value exactly, so - // the normal map MODULATES the bake instead of being able to cancel it. 1.0 is the - // unfaded game behaviour, 0.0 is the pure bake. - // This only affects the BAKED term - the dynamic sun path keeps the full-strength - // normal, since it has no baked intensity to preserve. - vec3 bakedNormalTS = normalize(vec3(derivativeSum * uBakedBumpFade, 1.0F)); - float bakedNdotL = clamp(dot(bakedLightDirTS, bakedNormalTS), 0.0F, 1.0F); - // Directional-lightmap renormalisation: dividing by the light's own .z makes a FLAT - // normal reproduce the baked intensity exactly, so the normal map only modulates - // around it instead of darkening everything. This division is the signature of the - // technique, and the reason the bake can be authored independently of the normal map. - float bakedDiffuse = bakedLightDirTS.z > 0.0F ? bakedNdotL / bakedLightDirTS.z : bakedNdotL; - // NOT gamma-decoded, unlike the albedo. This is a light INTENSITY map, not an - // sRGB-authored colour: measured across metropolis its median texel is 41/255 (~0.16), - // and a 2.2 decode drops that to 0.016 - the whole scene reads as black. Only the - // albedo gets the sRGB round trip; light values enter the linear math as stored. - // EXPOSURE. The bake is genuinely dark as stored: measured on metropolis's terrain - // atlases, non-black texels average 32/255 (~0.13), so albedo * light lands near black - // even though the atlas itself is correct - verified by unswizzling one and confirming - // 11 of 13 sampled UFrag islands land on lit data with their own atlas. - // The game has a multiplier here that we cannot source: diffuseTerm = lightScale * nDotL, - // where lightScale is tc1.z, i.e. the per-draw vertex constant vc[2].z. Packing baked - // HDR lighting into 8 bits and scaling back up on read is the normal reason for such a - // constant to exist. This stands in for it. - // Raise until lit surfaces match the game; true-black texels stay black either way, - // since this scales rather than lifts. If shadows then look too absolute rather than - // too dark, that residue is the missing environment-cubemap fill, not this value. - vec3 bakedDiffuseLight = bakedColour.rgb * bakedDiffuse * uBakedLightScale; - - // ---- COMPOSITE ------------------------------------------------------------------ - // Baked lighting replaces the dynamic sun entirely where present - they are two - // answers to the same question, and summing them would double-light the scene. - // Emissive stays INSIDE the light term either way, so it multiplies by albedo. - // THE GAME HAS NO DYNAMIC LIGHT. Every static surface's lighting is baked - either into - // a lightmap (0x5400/0x5410, the hasBaked path above) or per-VERTEX for geometry without - // one; only the player character gets computed shadows. So there is no directional sun - // to evaluate here, and fabricating one actively misrepresents the game: it lit the - // surfaces we haven't decoded with invented shading, which is why untextured-looking - // greys appeared next to correctly-baked terrain. - // Surfaces WITHOUT a decoded bake therefore get a flat editor fill (uAmbient) rather - // than a fake N.L - honest about being undecoded instead of pretending to be lit. The - // real value for them is the per-vertex modulator the captured vertex program builds as - // tc1.x = fract(abs(in_pos.w) * vc[1].zw).x * vc[11].z - // where in_pos.w is the 4th short after the position: UFragVertex.unk, and the same slot - // ties call VertexFormat0.boneIndex. The fragment program then uses it as - // albedo = albedoScale * baseColour, i.e. it IS the vertex-baked light. - // Corroborated by Insomniac's own WWS debrief (Feb 08, dev/Ratchet_and_Clank_WWS_- - // Debrief_Feb_08.pdf), which states outright that their baked lighting is a MIX of - // lightmaps and per-vertex data. So the geometry carrying no lightmap index is not - // broken or unfinished - it is the other half of the intended system, and a complete - // implementation needs both paths. Decoding it - // needs vc[1].zw and vc[11].zw from a vertex-constants capture; fract() implies the - // field packs more than one value, so guessing is not viable. - vec3 undecodedFill = vec3(uAmbient); - vec3 lighting = mix(undecodedFill, bakedDiffuseLight, hasBaked) + emissiveIntensity; - - // The game modulates specular by the baked light buffer's ALPHA (monochrome specular - // light, per the deck's "don't allocate a separate specular buffer" optimisation). - // Note 84% of this level's lightmaps are DXT1 and therefore have no authored alpha - - // those decode to 1.0, leaving specular unattenuated, which is the correct outcome for - // a channel that was never authored. - float bakedSpecLight = mix(1.0F, bakedColour.a, hasBaked); - // ENVIRONMENT FILL - a flat approximation of the game's cubemap reflection, which is - // additive and NOT gated by the lightmap. That is what stops baked shadows reaching - // pure black in the game: our shadowed areas fall to exactly albedo * 0 because we have - // no environment term at all. Metropolis's cubemap is a near-uniform grey, so a single - // averaged colour reproduces most of its contribution. - // TINTED BY ALBEDO, which is the part that matters. The captured shader computes - // specularTint = fma(albedo - specTintPivot, specTintContrast, specTintOffset) - // and then specular = envColour * specularTint * specularLight * specularAmount, so the - // reflection carries the surface's own hue. An UNTINTED version of this term was what - // desaturated the whole scene: adding a uniform grey after the albedo multiply pushes - // every colour toward grey by construction, which read as "everything looks white / - // not coloured" while the game stays warm and saturated. - // The three tint constants are unidentified, so this uses albedo directly - the - // contrast=1, pivot=0, offset=0 collapse of that expression. Recovering the real - // constants (fc[6].xyz, fc[7]) needs the fragment-constants buffer from a capture. - vec3 specularTint = albedo; - vec3 envFill = uEnvironmentColour * specularTint * uEnvironmentIntensity * specIntensity * bakedSpecLight; - // The dynamic sun's Phong highlight is gated OFF where a bake exists, same rule as the - // diffuse term: the game's specular on baked surfaces IS the cubemap reflection (which - // envFill stands in for), not a directional-light lobe. Leaving both on double-counted - // specular on every baked surface (read as "speculars look exaggerated"). - // No Phong lobe at all: it modelled a directional light this game does not have. The - // only specular the game applies is the cubemap reflection, which envFill approximates. - vec3 specPart = envFill; - - vec3 finalColor = albedo * lighting + specPart; - - // Debug: the raw bake with no albedo or shading. Baked surfaces show their scaled - // lightmap texel, everything else drops to flat mid-grey - so a black surface is - // immediately either "the bake is black here" or "shading is killing it". - if (uBakedDebugView > 0.5F) { - finalColor = mix(vec3(0.25F), bakedColour.rgb * uBakedLightScale, hasBaked); - } - - finalColor = pow(finalColor, vec3(1.0F / 2.2F)); - fFragColor = vec4(finalColor, texelColor.a * maps[0].color.a * fColor.a); - } - """; + public static string Vertex => ShaderAsset.Load("litmodelv.glsl"); + public static string Fragment => ShaderAsset.Load("litmodelf.glsl"); } diff --git a/ReLunacy.Engine/Rendering/Shaders/ShaderAsset.cs b/ReLunacy.Engine/Rendering/Shaders/ShaderAsset.cs new file mode 100644 index 0000000..5d52702 --- /dev/null +++ b/ReLunacy.Engine/Rendering/Shaders/ShaderAsset.cs @@ -0,0 +1,36 @@ +namespace ReLunacy.Engine.Rendering.Shaders; + +/// Loads GLSL shader assets shipped alongside the executable, from the output Shaders/ +/// directory. That folder is the FUSION of the app's own shaders (ReLunacy/Shaders, e.g. the ImGui +/// and picking shaders) and the engine's model shaders (ReLunacy.Engine/Shaders) - both projects +/// copy their Shaders tree to the same output location, so a flat name resolves regardless of which +/// project shipped it. +/// +/// Resolves against (the running executable's directory) +/// rather than the engine assembly's own path: the engine is a library with no output of its own at +/// runtime, and its content files are copied into the host app's output next to the .exe. +/// +/// ASCII ONLY inside these .glsl files, comments included: a single non-ASCII byte makes the runtime +/// shaderc compile fail with a MISLEADING "unexpected end of file" error (see LitModelShaderSource). +/// +/// Contents are cached after first read - shader sources don't change at runtime, and every Effect +/// rebuild (e.g. toggling lighting) would otherwise re-hit the disk. +public static class ShaderAsset +{ + private static readonly string Root = Path.Combine(AppContext.BaseDirectory, "Shaders"); + private static readonly Dictionary Cache = []; + + public static string Load(string fileName) + { + if (Cache.TryGetValue(fileName, out var cached)) + return cached; + + string path = Path.Combine(Root, fileName); + if (!File.Exists(path)) + throw new FileNotFoundException($"Shader asset '{fileName}' not found under '{Root}'. Is the Shaders/ content copied to the output?", path); + + string text = File.ReadAllText(path); + Cache[fileName] = text; + return text; + } +} diff --git a/ReLunacy.Engine/Rendering/Shaders/VertexAlphaModelShaderSource.cs b/ReLunacy.Engine/Rendering/Shaders/VertexAlphaModelShaderSource.cs index b7f6bb8..4fb8502 100644 --- a/ReLunacy.Engine/Rendering/Shaders/VertexAlphaModelShaderSource.cs +++ b/ReLunacy.Engine/Rendering/Shaders/VertexAlphaModelShaderSource.cs @@ -1,83 +1,15 @@ namespace ReLunacy.Engine.Rendering.Shaders; // Identical to Bliss's bundled content/bliss/shaders/default_model.vert/.frag, except vColor is -// actually passed through and multiplied in — the bundled version declares vColor as a vertex +// actually passed through and multiplied in - the bundled version declares vColor as a vertex // input but never forwards it past the vertex stage (confirmed by reading its real source), so // there's no way to make ordinary materials consume it without a second shader. Kept as our own // Effect (see AssetManager.GetVertexAlphaModelEffect) instead of patching the vendored content // files, which get overwritten by every NuGet restore and are shared by every other material. +// +// Shader source lives in Shaders/vertexalphav.glsl / vertexalphaf.glsl (see ShaderAsset). internal static class VertexAlphaModelShaderSource { - public const string Vertex = """ - #version 450 - - layout(std140, set = 0, binding = 0) uniform MatrixBuffer { - mat4x4 uProjection; - mat4x4 uView; - }; - - layout(std140, set = 1, binding = 0) uniform TransformBuffer { - mat4x4 uTransformation; - }; - - layout (location = 0) in vec3 vPosition; - layout (location = 1) in vec2 vTexCoords; - layout (location = 2) in vec2 vTexCoords2; - layout (location = 3) in vec3 vNormal; - layout (location = 4) in vec4 vTangent; - layout (location = 5) in vec4 vColor; - - layout (location = 0) out vec2 fTexCoords; - layout (location = 1) out vec4 fColor; - - void main() { - fTexCoords = vTexCoords; - fColor = vColor; - - mat4x4 transformation = uTransformation; - vec4 v4Pos = vec4(vPosition, 1.0F); - gl_Position = uProjection * uView * transformation * v4Pos; - } - """; - - public const string Fragment = """ - #version 450 - - #define MAX_MAPS_COUNT 8 - - struct MaterialMap { - vec4 color; - float value; - }; - - layout(std140, set = 2, binding = 0) uniform MaterialBuffer { - int renderMode; - MaterialMap maps[MAX_MAPS_COUNT]; - }; - - layout (set = 3, binding = 0) uniform texture2D fAlbedo; - layout (set = 3, binding = 1) uniform sampler fAlbedoSampler; - - layout (location = 0) in vec2 fTexCoords; - layout (location = 1) in vec4 fColor; - - layout (location = 0) out vec4 fFragColor; - - void main() { - vec4 texelColor = texture(sampler2D(fAlbedo, fAlbedoSampler), fTexCoords); - - switch (renderMode) { - case 0: - texelColor.a = 1.0F; - break; - case 1: - if (texelColor.a < 0.99F) { - discard; - } - break; - } - - fFragColor = texelColor * maps[0].color * fColor; - } - """; + public static string Vertex => ShaderAsset.Load("vertexalphav.glsl"); + public static string Fragment => ShaderAsset.Load("vertexalphaf.glsl"); } diff --git a/ReLunacy.Engine/Rendering/TextureFiltering.cs b/ReLunacy.Engine/Rendering/TextureFiltering.cs index cac38d9..1484df4 100644 --- a/ReLunacy.Engine/Rendering/TextureFiltering.cs +++ b/ReLunacy.Engine/Rendering/TextureFiltering.cs @@ -2,7 +2,7 @@ namespace ReLunacy.Engine.Rendering; /// /// How a texture is sampled by the 3D view. Deliberately its own enum (not Bliss's SamplerType, -/// which also encodes clamp-vs-wrap addressing) so call sites express intent only — game textures +/// which also encodes clamp-vs-wrap addressing) so call sites express intent only - game textures /// always wrap, and the mapping to an actual GPU sampler lives in exactly one place /// (AssetManager.GetSamplerFor). Designed to grow: future per-texture techniques (anisotropic, /// trilinear once mip chains are uploaded, ...) are new values here plus one switch arm there, @@ -11,7 +11,7 @@ namespace ReLunacy.Engine.Rendering; /// public enum TextureFiltering { - /// Nearest-neighbor. Crisp/blocky up close — the renderer's historical look. + /// Nearest-neighbor. Crisp/blocky up close - the renderer's historical look. Point, /// Bilinear interpolation between the 4 nearest texels. Bilinear, diff --git a/ReLunacy.Engine/Rendering/TextureUtils.cs b/ReLunacy.Engine/Rendering/TextureUtils.cs index a60a5d4..86b9280 100644 --- a/ReLunacy.Engine/Rendering/TextureUtils.cs +++ b/ReLunacy.Engine/Rendering/TextureUtils.cs @@ -1,6 +1,5 @@ using System.Numerics; -using Bliss.CSharp.Colors; -using Bliss.CSharp.Images; +using ReLunacy.Engine.Rendering.Resources; using ReLunacy.Engine.Assets.Interfaces; using TinyBCSharp; @@ -16,7 +15,7 @@ public RGBA8888(Vector4 vec4) Alpha = (byte)(vec4.W * 255); } - public RGBA8888(Color color) + public RGBA8888(RgbaColor color) { Red = color.R; Green = color.G; @@ -37,7 +36,7 @@ public RGBA8888(byte red = 0xFF, byte green = 0xFF, byte blue = 0xFF, byte alpha public byte Blue; public byte Alpha; - public readonly Color ToBlissColor() => new(Red, Green, Blue, Alpha); + public readonly RgbaColor ToColor() => new(Red, Green, Blue, Alpha); public readonly Vector4 ToNormalizedVector() => new(Red / (float)0xFF, Green / (float)0xFF, Blue / (float)0xFF, Alpha / (float)0xFF); } @@ -48,16 +47,16 @@ public static class TextureUtils private static readonly BlockDecoder Bc3Decoder = BlockDecoder.Create(BlockFormat.BC3); // Unsigned variants: no confirmed case in this game's assets needs signed BC4/BC5 data, and // ReconstructZ (which derives a normal map's Z from X/Y) isn't used here since this decode - // path is generic — it's shared by plain texture export too, where injecting a normal-map + // path is generic - it's shared by plain texture export too, where injecting a normal-map // assumption into every BC5 texture would be wrong. private static readonly BlockDecoder Bc4Decoder = BlockDecoder.Create(BlockFormat.BC4U); private static readonly BlockDecoder Bc5Decoder = BlockDecoder.Create(BlockFormat.BC5U); /// /// Decodes an ITexture's raw (possibly block-compressed) pixel data to a plain RGBA8888 - /// buffer — the one decode path shared by AssetManager (GPU texture upload) and the model + /// buffer - the one decode path shared by AssetManager (GPU texture upload) and the model /// exporters (PNG encoding for glTF/OBJ), so the format-conversion switch isn't duplicated. - /// Returns null if the texture has no data (some slots legitimately have none — see + /// Returns null if the texture has no data (some slots legitimately have none - see /// AssetManager.GetOrBuildTexture) or an unrecognized format. /// public static byte[]? DecodeToRgba8888(ITexture texture, out int width, out int height) @@ -87,9 +86,9 @@ public static class TextureUtils } /// - /// This game's normal maps are NOT a standard tangent-space (nx,ny,nz) encoding — they store + /// This game's normal maps are NOT a standard tangent-space (nx,ny,nz) encoding - they store /// partial derivatives instead: dx = -nx/nz, dy = -ny/nz. The game reconstructs the real - /// normal on the GPU in just two instructions: n = normalize(vec3(-dx, -dy, 1)) — storing the + /// normal on the GPU in just two instructions: n = normalize(vec3(-dx, -dy, 1)) - storing the /// slope directly (rather than a normalized direction) is what makes that cheap reconstruction /// possible, and it also means multiple normal contributions can be combined by plain addition /// in derivative space, unlike standard tangent-space normals which need a full reoriented- @@ -97,16 +96,16 @@ public static class TextureUtils /// /// Modern engines/DCC tools (and glTF's own normalTexture) expect the conventional (nx,ny,nz) /// encoding, so this reconstructs the real normal from the stored derivatives and repacks it - /// that way — for export only. ReLunacy's own live renderer (see AssetManager) still uploads + /// that way - for export only. ReLunacy's own live renderer (see AssetManager) still uploads /// the raw, unconverted derivative bytes to the GPU untouched by this function, so a future lit /// shader can do the exact same 2-instruction reconstruction the game itself does, for /// fidelity, rather than trusting this repacked copy as ground truth. /// - /// Confirmed layout: the two derivatives live in the decoded G and A channels — B is always + /// Confirmed layout: the two derivatives live in the decoded G and A channels - B is always /// constant (255/100%), R unused, regardless of the source compression format (this matches /// the common "DXT5nm"-style trick of putting normal-map data in Green and Alpha specifically, /// since those are the two channels DXT5 compresses with the most independent precision). - /// dx=Alpha, dy=Green — confirmed against Negotiator/TextureEditor, a separate working + /// dx=Alpha, dy=Green - confirmed against Negotiator/TextureEditor, a separate working /// reverse-engineering tool for this exact game's formats (TextureHelper.BitmapFromDDS's DXT5 /// normal-map path reads p.A for dx and p.G for dy), not G=dx/A=dy as originally guessed here. /// @@ -124,7 +123,7 @@ public static class TextureUtils float dx = rgba[i + 3] / 255f * 2f - 1f; // A float dy = rgba[i + 1] / 255f * 2f - 1f; // G - // No sign flip — see LitModelShaderSource's normal section. The game's own captured + // No sign flip - see LitModelShaderSource's normal section. The game's own captured // fragment shader uses the sampled derivatives directly as (dx, dy, 1); negating them // here (as this did) double-negates an already-negated ratio and inverts the relief on // every exported normal map. Kept identical to the live shader's reconstruction on @@ -194,7 +193,7 @@ public static Image ColourAsMain(this Image img, Colours colourFilter) { for (int x = 0; x < img.Width; x++) { - Color currCol = img.GetColor(x, y); + RgbaColor currCol = img.GetColor(x, y); byte pxlCol = colourFilter switch { Colours.Red => currCol.R, @@ -204,7 +203,7 @@ public static Image ColourAsMain(this Image img, Colours colourFilter) _ => 0, }; - img.SetPixel(x, y, new Color(pxlCol, pxlCol, pxlCol, 0xFF)); + img.SetPixel(x, y, new RgbaColor(pxlCol, pxlCol, pxlCol, 0xFF)); } } @@ -213,18 +212,18 @@ public static Image ColourAsMain(this Image img, Colours colourFilter) /// Reassembles a big-endian (disk-order) 16-bit pixel from a 2-byte source. All the /// 16-bit format decoders below read from data already loaded as-is off disk (StreamHelper's - /// stream is big-endian, and Texture.Unswizzle/ReadTexture don't reorder bytes — see those for + /// stream is big-endian, and Texture.Unswizzle/ReadTexture don't reorder bytes - see those for /// why), so byte0 is always the high byte. private static ushort ReadPixel16(byte[] rawData, int i) => (ushort)((rawData[i * 2] << 8) | rawData[i * 2 + 1]); /// Expands an N-bit channel value to 8 bits by replicating its high bits into the low - /// bits (e.g. 5-bit 11111 -> 11111111, not 11111000) — the standard bit-replication expansion, + /// bits (e.g. 5-bit 11111 -> 11111111, not 11111000) - the standard bit-replication expansion, /// avoids the low end of the range never reaching full brightness/darkness. private static byte Expand(int value, int bits) => (byte)((value << (8 - bits)) | (value >> (2 * bits - 8))); // Previously computed R/B by right-shifting a 5-bit field into the top of an 8-bit channel // with no expansion (max output ~0x1F, i.e. red/blue could never exceed ~12% brightness), and - // G by OR-ing an unshifted byte1 high-bits term against a shifted byte0 low-bits term — the + // G by OR-ing an unshifted byte1 high-bits term against a shifted byte0 low-bits term - the // two write to overlapping bit positions instead of adjacent ones, corrupting green on every // pixel. Fixed by unpacking the full 16-bit word first, then expanding each channel properly. public static byte[] RGB565ToRGBA8888(in byte[] rawData, int width, int height) @@ -249,7 +248,7 @@ public static byte[] RGB565ToRGBA8888(in byte[] rawData, int width, int height) return result; } - /// Bit layout (MSB->LSB) A1 R5 G5 B5 — matches the format name and the equivalent + /// Bit layout (MSB->LSB) A1 R5 G5 B5 - matches the format name and the equivalent /// bare-Vulkan/D3D "A1R5G5B5" convention, not independently confirmed against real data. public static byte[] A1RGB555ToRGBA8888(in byte[] rawData, int width, int height) { @@ -269,7 +268,7 @@ public static byte[] A1RGB555ToRGBA8888(in byte[] rawData, int width, int height return result; } - /// Bit layout (MSB->LSB) R4 G4 B4 A4, following the format name's channel order — + /// Bit layout (MSB->LSB) R4 G4 B4 A4, following the format name's channel order - /// unconfirmed against real data; if colors look swapped/tinted on a real RGBA4 texture, this /// is the first thing to try reordering (e.g. to A4R4G4B4). public static byte[] RGBA4444ToRGBA8888(in byte[] rawData, int width, int height) @@ -311,7 +310,7 @@ public static byte[] R8ToRGBA8888(in byte[] rawData, int width, int height) } /// byte0=G, byte1=B per the format name's order (commonly a 2-channel tangent-space - /// normal map XY pair in other engines, but that's not confirmed for this game) — unconfirmed + /// normal map XY pair in other engines, but that's not confirmed for this game) - unconfirmed /// against real data, same caveat as RGBA4444ToRGBA8888. public static byte[] G8B8ToRGBA8888(in byte[] rawData, int width, int height) { @@ -331,7 +330,7 @@ public static byte[] G8B8ToRGBA8888(in byte[] rawData, int width, int height) } /// 4x 16-bit half-float channels (RGBA), clamped to [0,1] and scaled to 8-bit since - /// the output target here is always an LDR buffer (GPU upload or PNG export) — HDR values + /// the output target here is always an LDR buffer (GPU upload or PNG export) - HDR values /// above 1.0 just clip rather than tone-map. Byte order matches ReadPixel16 (big-endian /// disk-order halves). public static byte[] RGBA16FToRGBA8888(in byte[] rawData, int width, int height) diff --git a/ReLunacy.Engine/Rendering/Vulkan/VkMaterialBuilder.cs b/ReLunacy.Engine/Rendering/Vulkan/VkMaterialBuilder.cs new file mode 100644 index 0000000..d4baf45 --- /dev/null +++ b/ReLunacy.Engine/Rendering/Vulkan/VkMaterialBuilder.cs @@ -0,0 +1,51 @@ +using ReLunacy.Engine.Rendering.Resources; +using Veldrith; + +namespace ReLunacy.Engine.Rendering.Vulkan; + +/// Turns a built material into the raw-Vulkan renderer's . +/// +/// Shared by every view that feeds the renderer a scene (the level view and the asset preview), so the +/// two cannot drift into shading the same material differently - which is exactly the sort of thing +/// that makes a preview a bad reference for the real thing. +public static class VkMaterialBuilder +{ + public static VkMaterialDesc Build(RenderMaterial material, AssetManager? assetManager) + { + byte gameRenderMode = 0; + bool usesVertexAlpha = false; + bool albedoHasAlphaChannel = false; + assetManager?.TryGetVkMaterialInfo(material, out gameRenderMode, out usesVertexAlpha, out albedoHasAlphaChannel); + + return new VkMaterialDesc + { + Albedo = TextureOf(material, MaterialMapType.Albedo), + Normal = TextureOf(material, MaterialMapType.Normal), + Props = TextureOf(material, "fProperties"), + LightColour = TextureOf(material, "fLightColour"), + LightDir = TextureOf(material, "fLightDir"), + // fLightColour's value slot is the "this material has a real bake" flag. + HasBaked = ValueOf(material, "fLightColour"), + ParallaxScale = ValueOf(material, "fParallaxScale"), + ParallaxBias = ValueOf(material, "fParallaxBias"), + AlphaThreshold = ValueOf(material, MaterialMapType.Albedo), + // The game's own 0-6 mode + vertex-alpha flags come from AssetManager's side table rather + // than a map slot: none of them is a texture, and the renderer wants all three in one lookup. + GameRenderMode = gameRenderMode, + UsesVertexAlpha = usesVertexAlpha ? 1f : 0f, + AlbedoHasAlphaChannel = albedoHasAlphaChannel ? 1f : 0f, + // Foliage sprite cards are billboarded in the vertex shader from data packed into the + // geometry, so they need the billboard pipeline rather than the lit one. + IsBillboard = assetManager != null && assetManager.IsBillboardMaterial(material) ? 1f : 0f, + }; + } + + /// A material map's texture as a Veldrith texture. Every material has + /// albedo/normal/properties/fLightColour/fLightDir maps (AssetManager provides defaults), so these + /// are normally non-null; null is handled by the renderer. + private static Texture? TextureOf(RenderMaterial material, MaterialMapKey key) => + material.GetMaterialMap(key)?.Texture?.DeviceTexture; + + private static float ValueOf(RenderMaterial material, MaterialMapKey key) => + material.GetMaterialMap(key)?.Value ?? 0f; +} diff --git a/ReLunacy.Engine/Rendering/Vulkan/VulkanContext.cs b/ReLunacy.Engine/Rendering/Vulkan/VulkanContext.cs new file mode 100644 index 0000000..84d799c --- /dev/null +++ b/ReLunacy.Engine/Rendering/Vulkan/VulkanContext.cs @@ -0,0 +1,52 @@ +using Veldrith; +using Vortice.Vulkan; + +namespace ReLunacy.Engine.Rendering.Vulkan; + +/// Stage 0 of the from-scratch raw-Vulkan renderer (see Docs/NewRenderer.md). +/// +/// The new renderer does NOT create its own Vulkan instance/device - during the staged migration it +/// SHARES Veldrith's, so the window, swapchain, ImGui and present all keep working while only the +/// scene pass moves to raw Vulkan. Veldrith exposes the handles via +/// GraphicsDevice.GetVulkanInfo(out BackendInfoVulkan) as raw s; this +/// wraps them into the Vortice.Vulkan handle types (pinned to Veldrith's exact Vortice version so +/// they are the same structs). From here later stages build their own command pool, pipelines, +/// descriptor sets and SIMULTANEOUS_USE command buffers to get record-once/replay - the thing +/// Veldrith's ONE_TIME_SUBMIT command lists cannot do. +public sealed class VulkanContext +{ + public VkInstance Instance { get; } + public VkPhysicalDevice PhysicalDevice { get; } + public VkDevice Device { get; } + public VkQueue GraphicsQueue { get; } + public uint GraphicsQueueFamilyIndex { get; } + + /// Vortice's per-instance and per-device function tables. Veldrith's own + /// (VkGraphicsDevice.DeviceApi) is on a private type we can't reach, so we build our own bound to + /// the SAME shared handles. The base loader is already initialised by Veldrith, so + /// Vulkan.GetApi/new VkDeviceApi just resolve entry points against these handles. + public VkInstanceApi InstanceApi { get; } + public VkDeviceApi DeviceApi { get; } + + /// Kept so the renderer can call GetVkImage(Veldrith.Texture) - the only supported + /// way to reach a Veldrith-owned texture's raw VkImage, which is how the scene renders into an + /// image ImGui already displays. + public BackendInfoVulkan BackendInfo { get; } + + public VulkanContext(GraphicsDevice graphicsDevice) + { + if (!graphicsDevice.GetVulkanInfo(out BackendInfoVulkan info)) + throw new InvalidOperationException( + "The new renderer requires the Vulkan backend; GraphicsDevice.GetVulkanInfo failed."); + BackendInfo = info; + + Instance = new VkInstance(info.Instance); + PhysicalDevice = new VkPhysicalDevice(info.PhysicalDevice); + Device = new VkDevice(info.Device); + GraphicsQueue = new VkQueue(info.GraphicsQueue); + GraphicsQueueFamilyIndex = info.GraphicsQueueFamilyIndex; + + InstanceApi = Vortice.Vulkan.Vulkan.GetApi(Instance); + DeviceApi = new VkDeviceApi(InstanceApi, Device); + } +} diff --git a/ReLunacy.Engine/Rendering/Vulkan/VulkanRenderer.cs b/ReLunacy.Engine/Rendering/Vulkan/VulkanRenderer.cs new file mode 100644 index 0000000..e132de4 --- /dev/null +++ b/ReLunacy.Engine/Rendering/Vulkan/VulkanRenderer.cs @@ -0,0 +1,2241 @@ +using System.Numerics; +using Veldrith; +using Veldrith.SPIRV; +using Vortice.Vulkan; + +namespace ReLunacy.Engine.Rendering.Vulkan; + +/// The from-scratch raw-Vulkan scene renderer (Docs/NewRenderer.md). +/// +/// One command buffer, re-recorded per frame with only the frustum- and distance-visible draws, then +/// one submit. Draws are per-instance INDEXED draws (never hardware instancing) the way the original +/// engine did them; the performance comes from recording once per frame instead of per entity, not +/// from batching them away. +/// +/// Frame structure, three render passes over a shared depth-stencil buffer: +/// 1. OPAQUE Opaque + Cutout, then Soft-Edge's alpha-tested depth-only prepass, then Additive, +/// then foliage billboards, then the editor's volume wireframes. Colour + depth. +/// 2. ACCUMULATE Overlay/Scunge/Blended and Soft-Edge's colour pass, blended commutatively into an +/// RGBA16F accum and an R16F reveal target (McGuire/Bavoil weighted-blended OIT), depth- +/// tested but not depth-writing - so translucency needs no sorting and no re-record. +/// 3. RESOLVE A fullscreen triangle composites accum/reveal over the opaque colour, then the +/// selection outline draws on top of the finished image. +/// +/// Every non-opaque draw carries the game's polygon offset (see NonOpaqueDepthBias). Bliss's own lit +/// shader is untouched: this renderer compiles its own SPIR-V. +public sealed unsafe class VulkanRenderer : IDisposable +{ + private const VkFormat ColorFormat = VkFormat.R8G8B8A8Unorm; + // D32_SFLOAT_S8_UINT rather than plain D32_SFLOAT: the selection outline is a stencil + // mask-and-inflate technique (a plain inflated hull does not work on these assets: neither + // their winding nor their vertex normals are reliable), so the depth buffer carries stencil. + private const VkFormat DepthFormat = VkFormat.D32SfloatS8Uint; + private const VkFormat AccumFormat = VkFormat.R16G16B16A16Sfloat; + private const VkFormat RevealFormat = VkFormat.R16Sfloat; + private const uint VertexStride = VulkanSceneCapture.FloatsPerVertex * sizeof(float); // 56 + private const int TexPerMaterial = 5; + + // Polygon offset for the non-opaque pass, measured from a RenderDoc capture of the real game + // (identical values in AssetManager, which applies them on the Bliss path). + private const float NonOpaqueDepthBias = -87f; + private const float NonOpaqueSlopeScaledDepthBias = -0.33972f; + + private const string VertexGlsl = @"#version 450 +layout(set = 0, binding = 0) uniform Mvp { mat4 uMvp; }; +layout(set = 0, binding = 1) readonly buffer Transforms { mat4 uT[]; }; +layout(location = 0) in vec3 inPos; +layout(location = 1) in vec2 inUV; +layout(location = 2) in vec3 inNormal; +layout(location = 3) in vec4 inTangent; +layout(location = 4) in vec2 inUV2; +layout(location = 5) in vec4 inColor; +layout(location = 0) out vec2 fUV; +layout(location = 1) out vec3 fWorldNormal; +layout(location = 2) out vec3 fWorldTangent; +layout(location = 3) out float fHandedness; +layout(location = 4) out vec3 fWorldPos; +layout(location = 5) out vec2 fUV2; +layout(location = 6) out vec4 fColor; +void main() { + mat4 m = uT[gl_InstanceIndex]; + mat3 m3 = mat3(m); + mat3 nrm = transpose(inverse(m3)); + fWorldNormal = normalize(nrm * inNormal); + fWorldTangent = normalize(nrm * inTangent.xyz); + fHandedness = inTangent.w * sign(determinant(m3)); + vec4 world = m * vec4(inPos, 1.0); + fWorldPos = world.xyz; + fUV = inUV; + fUV2 = inUV2; + fColor = inColor; + gl_Position = uMvp * world; +}"; + + // Shared lit shading: everything except the final output. Both the opaque and the accumulate + // fragment shaders append their own main() and output declarations to this. + private const string LitFragCommon = @"#version 450 +layout(set = 0, binding = 2, std140) uniform LightBuffer { + vec3 uLightDirection; float uAmbient; + vec3 uLightColor; float uSpecularPower; + vec3 uCameraPosition; float uReflectionDebug; + vec3 uEnvironmentColour; float uEnvironmentIntensity; + vec2 uLightmapUVScale; vec2 uLightmapUVOffset; + float uBakedLightScale; float uBakedBumpFade; float uBakedDebugView; float uReflectionBase; + vec2 uLightmapUVPivot; float uLightmapUVRotation; float uBakedAmbient; + vec3 uEnvDirection0; float uEnvHasLighting; + vec3 uEnvDirection1; float _p4; + vec3 uEnvAmbient; float _p5; + vec3 uEnvLight0Colour; float _p6; + vec3 uEnvLight1Colour; float _p7; +}; +layout(set = 0, binding = 3) uniform samplerCube uEnvCube; +layout(set = 1, binding = 0) uniform sampler2D uAlbedo; +layout(set = 1, binding = 1) uniform sampler2D uNormal; +layout(set = 1, binding = 2) uniform sampler2D uProps; +layout(set = 1, binding = 3) uniform sampler2D uLightColour; +layout(set = 1, binding = 4) uniform sampler2D uLightDir; +layout(push_constant) uniform PC { vec4 uMat0; vec4 uMat1; }; // uMat0=(hasBaked,pScale,pBias,alphaThr); uMat1=(renderMode,vtxAlpha,lit,albedoHasAlpha) +layout(location = 0) in vec2 fUV; +layout(location = 1) in vec3 fWorldNormal; +layout(location = 2) in vec3 fWorldTangent; +layout(location = 3) in float fHandedness; +layout(location = 4) in vec3 fWorldPos; +layout(location = 5) in vec2 fUV2; +layout(location = 6) in vec4 fColor; +void shade(out vec3 litColor, out float litAlpha) { + vec3 n = normalize(fWorldNormal); + vec3 t = normalize(fWorldTangent - n * dot(fWorldTangent, n)); + vec3 b = cross(n, t) * fHandedness; + mat3 tbn = mat3(t, b, n); + vec3 viewDir = normalize(uCameraPosition - fWorldPos); + vec3 viewDirTS = transpose(tbn) * viewDir; + float height = texture(uProps, fUV).g * uMat0.y + uMat0.z; + vec2 uv = fUV + viewDirTS.xy * height; + vec4 albedoTex = texture(uAlbedo, uv); + // Alpha test, GEQUAL against the mode's hardcoded reference (uMat0.w; 0 = no test). Cutout uses + // 128/255, the blended paths 4/255 - see dev/chatgpt-eboot-{2,3,5}.txt. Sourced like litAlpha below. + float testAlpha = uMat1.y > 0.5 ? (uMat1.w > 0.5 ? albedoTex.a * fColor.a : fColor.a) : albedoTex.a; + if (uMat0.w > 0.0 && testAlpha < uMat0.w) discard; + vec4 nrmSample = texture(uNormal, uv); + vec2 derivativeSum = vec2(nrmSample.a * 2.0 - 1.0, nrmSample.g * 2.0 - 1.0); + vec3 worldNormal = normalize(tbn * normalize(vec3(derivativeSum, 1.0))); + vec4 props = texture(uProps, uv); + float specIntensity = props.r; + float emissive = props.b; + vec3 albedo = pow(albedoTex.rgb, vec3(2.2)); // (Color.rgb is always white in this engine - no vertex tint) + vec3 envDiffuse = uEnvAmbient + + uEnvLight0Colour * max(dot(worldNormal, uEnvDirection0), 0.0) + + uEnvLight1Colour * max(dot(worldNormal, uEnvDirection1), 0.0); + vec3 undecodedFill = mix(vec3(uAmbient), envDiffuse, uEnvHasLighting); + vec2 uvCentred = fUV2 - uLightmapUVPivot; + float sr = sin(radians(uLightmapUVRotation)); + float cr = cos(radians(uLightmapUVRotation)); + vec2 uvRot = vec2(uvCentred.x * cr - uvCentred.y * sr, uvCentred.x * sr + uvCentred.y * cr) + uLightmapUVPivot; + vec2 bakedUV = uvRot * uLightmapUVScale + uLightmapUVOffset; + vec4 bakedColour = texture(uLightColour, bakedUV); + vec4 bakedDirSample = texture(uLightDir, bakedUV); + vec3 bakedLightDirTS = vec3(bakedDirSample.r, bakedDirSample.b, bakedDirSample.g); + float bl = length(bakedLightDirTS); + bakedLightDirTS = bl > 0.0 ? bakedLightDirTS / bl : vec3(0.0, 0.0, 1.0); + vec3 bakedNormalTS = normalize(vec3(derivativeSum * uBakedBumpFade, 1.0)); + float bakedNdotL = clamp(dot(bakedLightDirTS, bakedNormalTS), 0.0, 1.0); + float bakedDiffuse = bakedLightDirTS.z > 0.0 ? bakedNdotL / bakedLightDirTS.z : bakedNdotL; + vec3 bakedDiffuseLight = bakedColour.rgb * bakedDiffuse * uBakedLightScale; + float hasBaked = uMat0.x; + // A bake REPLACES the ambient fill rather than adding to it, because the bake already carries the + // bounce. But its N.L clamps to zero, so a parallax-perturbed normal that tilts past the baked + // light direction lands on exactly black. uBakedAmbient keeps a fraction of the fill underneath as + // a floor - the bake's own shadows still read, they just stop bottoming out. + vec3 bakedFloor = undecodedFill * uBakedAmbient; + vec3 lighting = mix(undecodedFill, max(bakedDiffuseLight, bakedFloor), hasBaked) + emissive; + float bakedSpecLight = mix(1.0, bakedColour.a, hasBaked); + vec3 reflDir = reflect(-viewDir, worldNormal); + vec4 envTexel = texture(uEnvCube, reflDir); + float envExposure = exp2((envTexel.a * 255.0 - 128.0) / 16.0); + vec3 envColour = envTexel.rgb * envExposure; + float NdotV = clamp(dot(worldNormal, viewDir), 0.0, 1.0); + float fresnel = uReflectionBase + (1.0 - uReflectionBase) * pow(1.0 - NdotV, 5.0); + float reflectivity = clamp(specIntensity + fresnel, 0.0, 1.0); + vec3 envFill = envColour * albedo * uEnvironmentIntensity * reflectivity * bakedSpecLight; + // UNLIT (uMat1.z == 0): the albedo straight through, still parallax-offset and alpha-tested so the + // surface keeps its real silhouette and texel. Everything above still runs - the compiler drops it, + // and branching around it would need the texture fetches hoisted out anyway (they feed the test). + litColor = uMat1.z > 0.5 + ? pow(albedo * lighting + envFill, vec3(1.0 / 2.2)) + : albedoTex.rgb; + // Opacity combines vertex alpha and the albedo's own alpha for any material with decoded vertex + // alpha to contribute (uMat1.y - any non-Opaque mode). MULTIPLY, not select: vertex alpha (a + // decal fade, LOD dither, etc.) and the texture's own alpha are independent sources, not + // mutually exclusive ones - a glass pane with edge falloff baked into vertex colour can still + // have its own alpha-cut leaf pattern. The albedo's alpha only enters that product when it is + // real (uMat1.w, AlbedoHasAlphaChannel) - a format with no alpha channel decodes to garbage + // there, so a no-alpha albedo contributes nothing and vertex alpha alone stands in for it. + // Materials with nothing to contribute (Opaque, uMat1.y == 0) read straight from the albedo. + litAlpha = uMat1.y > 0.5 ? (uMat1.w > 0.5 ? albedoTex.a * fColor.a : fColor.a) : albedoTex.a; +}"; + private const string FragmentOpaqueGlsl = LitFragCommon + @" +layout(location = 0) out vec4 o; +void main() { vec3 c; float a; shade(c, a); o = vec4(c, 1.0); }"; + // Weighted-blended OIT accumulation. accum sums premultiplied colour * weight; reveal multiplies + // down by (1 - alpha). Weight favours nearer, more-opaque fragments (McGuire's depth+alpha form). + private const string FragmentAccumGlsl = LitFragCommon + @" +layout(location = 0) out vec4 accum; +layout(location = 1) out float reveal; +void main() { + vec3 c; float a; shade(c, a); + float w = clamp(pow(min(1.0, a * 10.0) + 0.01, 3.0) * 1e8 * pow(1.0 - gl_FragCoord.z * 0.9, 3.0), 1e-2, 3e3); + accum = vec4(c * a, a) * w; + reveal = a; +}"; + // Additive (mode 2): the pipeline blends SrcAlpha/One, so the shader just outputs the lit colour and + // its alpha (which scales the contribution). No OIT needed - addition is order-independent already. + private const string FragmentAdditiveGlsl = LitFragCommon + @" +layout(location = 0) out vec4 o; +void main() { vec3 c; float a; shade(c, a); o = vec4(c, a); }"; + private const string ResolveVertexGlsl = @"#version 450 +void main() { + vec2 p = vec2((gl_VertexIndex << 1) & 2, gl_VertexIndex & 2); + gl_Position = vec4(p * 2.0 - 1.0, 0.0, 1.0); +}"; + // Composite: averageColor = accum.rgb / accum.a; blended over the opaque colour as + // averageColor*(1-reveal) + dst*reveal (see the resolve pipeline's blend factors). + private const string ResolveFragmentGlsl = @"#version 450 +layout(set = 0, binding = 0) uniform sampler2D uAccum; +layout(set = 0, binding = 1) uniform sampler2D uReveal; +layout(location = 0) out vec4 o; +void main() { + ivec2 c = ivec2(gl_FragCoord.xy); + vec4 accum = texelFetch(uAccum, c, 0); + float reveal = texelFetch(uReveal, c, 0).r; + vec3 avg = accum.rgb / max(accum.a, 1e-5); + o = vec4(avg, reveal); +}"; + + // Volumes: a unit-cube wireframe drawn per volume, depth-tested against the opaque scene (so they + // occlude correctly), coloured by a per-volume push constant (box matrix + colour). Provided fresh + // each frame by View3D, so selection colour / edits / culling just work. + // Debug lines: world-space segments with a per-vertex colour, drawn LAST and with the depth test + // off so they always read on top. This is what the ImmediateRenderer overlays used to do - skeleton + // bones, vertex markers, bounding spheres - none of which have any depth relationship to the mesh + // worth preserving (a bone inside a model must still be visible). + private const string DebugLineVertexGlsl = @"#version 450 +layout(set = 0, binding = 0) uniform Mvp { mat4 uMvp; mat4 uView; mat4 uProj; mat4 uPick; }; +layout(location = 0) in vec3 inPos; +layout(location = 1) in vec4 inColor; +layout(location = 0) out vec4 fColor; +void main() { fColor = inColor; gl_Position = uMvp * vec4(inPos, 1.0); }"; + + private const string DebugLineFragmentGlsl = @"#version 450 +layout(location = 0) in vec4 fColor; +layout(location = 0) out vec4 outColor; +void main() { outColor = fColor; }"; + + // GPU colour-ID picking. Instead of rasterizing the whole level to resolve a few pixels under the + // cursor, uPick is the view-projection post-multiplied by a clip-space window that blows just those + // pixels up to fill NDC - so the target is a few pixels square, and the same matrix's frustum planes + // reject everything that cannot be under the cursor before a single draw is issued. + private const string PickVertexGlsl = @"#version 450 +layout(set = 0, binding = 0) uniform Mvp { mat4 uMvp; mat4 uView; mat4 uProj; mat4 uPick; }; +layout(set = 0, binding = 1) readonly buffer Transforms { mat4 uT[]; }; +layout(location = 0) in vec3 inPos; +layout(location = 1) in vec2 inUV; +layout(location = 2) in vec3 inNormal; +layout(location = 3) in vec4 inTangent; +layout(location = 4) in vec2 inUV2; +layout(location = 5) in vec4 inColor; +void main() { gl_Position = uPick * (uT[gl_InstanceIndex] * vec4(inPos, 1.0)); }"; + + // Volume edges use their own per-draw world matrix rather than the transform SSBO, exactly like + // the visible wireframe pass, so that volumes stay selectable. + private const string PickVolumeVertexGlsl = @"#version 450 +layout(set = 0, binding = 0) uniform Mvp { mat4 uMvp; mat4 uView; mat4 uProj; mat4 uPick; }; +layout(push_constant) uniform Push { mat4 uWorld; uvec4 uId; }; +layout(location = 0) in vec3 inPos; +void main() { gl_Position = uPick * (uWorld * vec4(inPos, 1.0)); }"; + + // Foliage billboards need the same view-space corner offset BillboardVertexGlsl applies - without + // it every corner of a card projects to its shared anchor point, a zero-area triangle the + // rasterizer drops, so foliage would never appear in the pick target. uPickProj is the projection + // half of the windowed pick matrix (projection * window, see Pick()): the offset has to be added + // in view space, same as the main billboard pass, so the windowing can only be folded into the + // projection step rather than the combined view+projection uPick above. + private const string PickBillboardVertexGlsl = @"#version 450 +layout(set = 0, binding = 0) uniform Mvp { mat4 uMvp; mat4 uView; mat4 uProj; mat4 uPick; mat4 uPickProj; }; +layout(set = 0, binding = 1) readonly buffer Transforms { mat4 uT[]; }; +layout(location = 0) in vec3 inPos; +layout(location = 1) in vec2 inUV; +layout(location = 2) in vec3 inNormal; +layout(location = 3) in vec4 inTangent; +layout(location = 4) in vec2 inUV2; +layout(location = 5) in vec4 inColor; +void main() { + mat4 m = uT[gl_InstanceIndex]; + vec4 anchorView = uView * (m * vec4(inPos, 1.0)); + vec2 instanceScale = vec2(length(m[0].xyz), length(m[1].xyz)); + anchorView.xy += inUV2 * instanceScale; + gl_Position = uPickProj * anchorView; +}"; + + // The id is written as four bytes of an RGBA8 target (little-endian on readback), which keeps the + // whole path to plain colour attachments and needs no integer-format support. + private const string PickFragmentGlsl = @"#version 450 +layout(push_constant) uniform Push { mat4 uWorld; uvec4 uId; }; +layout(location = 0) out vec4 outColor; +void main() { + uint id = uId.x; + outColor = vec4( + float(id & 0xFFu) / 255.0, + float((id >> 8) & 0xFFu) / 255.0, + float((id >> 16) & 0xFFu) / 255.0, + float((id >> 24) & 0xFFu) / 255.0); +}"; + + // Foliage. Every vertex stores the sprite card's ANCHOR as its position and its own 2D corner + // offset in the lightmap UV slot; the card is turned to face the camera by adding that offset + // AFTER the view transform, so the vertex buffer is static and nothing is billboarded on the CPU. + // The offset is applied after the model matrix, so it would otherwise miss the placement's scale + // entirely - recovered here from the model matrix's own X/Y basis lengths. This mirrors + // billboardv.glsl; the only change is reading the world matrix from the transform SSBO. + private const string BillboardVertexGlsl = @"#version 450 +layout(set = 0, binding = 0) uniform Mvp { mat4 uMvp; mat4 uView; mat4 uProj; }; +layout(set = 0, binding = 1) readonly buffer Transforms { mat4 uT[]; }; +layout(location = 0) in vec3 inPos; +layout(location = 1) in vec2 inUV; +layout(location = 2) in vec3 inNormal; +layout(location = 3) in vec4 inTangent; +layout(location = 4) in vec2 inUV2; +layout(location = 5) in vec4 inColor; +layout(location = 0) out vec2 fUV; +layout(location = 1) out vec4 fColor; +void main() { + mat4 m = uT[gl_InstanceIndex]; + fUV = inUV; + fColor = inColor; + vec4 anchorView = uView * (m * vec4(inPos, 1.0)); + vec2 instanceScale = vec2(length(m[0].xyz), length(m[1].xyz)); + anchorView.xy += inUV2 * instanceScale; + gl_Position = uProj * anchorView; +}"; + + private const string BillboardFragmentGlsl = @"#version 450 +layout(set = 1, binding = 0) uniform sampler2D uAlbedo; +layout(push_constant) uniform Push { vec4 uMat0; vec4 uMat1; }; +layout(location = 0) in vec2 fUV; +layout(location = 1) in vec4 fColor; +layout(location = 0) out vec4 outColor; +void main() { + vec4 texel = texture(uAlbedo, fUV); + // Same combine as LitFragCommon.shade's testAlpha - see that comment. + float testAlpha = uMat1.y > 0.5 ? (uMat1.w > 0.5 ? texel.a * fColor.a : fColor.a) : texel.a; + if (uMat0.w > 0.0 && testAlpha < uMat0.w) discard; + outColor = vec4(texel.rgb * fColor.rgb, 1.0); +}"; + + // Selection outline: a two-pass stencil "mask and inflate". + // Pass 1 (uParams.x == 0) redraws the real geometry with colour writes off, stamping stencil 1 over + // the selected object's visible footprint. Pass 2 (uParams.x > 0) redraws it inflated in clip space + // with the stencil test set to NotEqual 1, so only the part of the hull sticking out past that + // footprint survives - the rim. The inflate-along-normals hull alone does not work on these assets + // (winding and vertex normals are both unreliable); the mask is what makes that failure impossible. + private const string OutlineVertexGlsl = @"#version 450 +layout(set = 0, binding = 0) uniform Mvp { mat4 uMvp; }; +layout(set = 0, binding = 1) readonly buffer Transforms { mat4 uT[]; }; +layout(push_constant) uniform Push { vec4 uColor; vec4 uParams; }; +layout(location = 0) in vec3 inPos; +layout(location = 1) in vec2 inUV; +layout(location = 2) in vec3 inNormal; +layout(location = 3) in vec4 inTangent; +layout(location = 4) in vec2 inUV2; +layout(location = 5) in vec4 inColor; +void main() { + mat4 m = uT[gl_InstanceIndex]; + vec4 clipPos = uMvp * (m * vec4(inPos, 1.0)); + if (uParams.x > 0.0) { + vec4 clipNormal = uMvp * (m * vec4(inNormal, 0.0)); + if (length(clipNormal.xy) > 0.0001) + clipPos.xy += normalize(clipNormal.xy) * uParams.x * clipPos.w; + } else { + // The mask redraws vertices the opaque pass already wrote depth for, through a different + // pipeline, so the LessEqual test is not guaranteed to win on bit-identical depth. Nudge + // toward the camera - far smaller than any real occlusion gap, so occlusion still masks out. + clipPos.z -= 0.0005 * clipPos.w; + } + gl_Position = clipPos; +}"; + + private const string OutlineFragmentGlsl = @"#version 450 +layout(push_constant) uniform Push { vec4 uColor; vec4 uParams; }; +layout(location = 0) out vec4 outColor; +void main() { outColor = uColor; }"; + + private const string VolumeVertexGlsl = @"#version 450 +layout(set = 0, binding = 0) uniform Mvp { mat4 uMvp; }; +layout(push_constant) uniform PC { mat4 uModel; vec4 uColor; }; +layout(location = 0) in vec3 inPos; +void main() { gl_Position = uMvp * (uModel * vec4(inPos, 1.0)); }"; + private const string VolumeFragmentGlsl = @"#version 450 +layout(push_constant) uniform PC { mat4 uModel; vec4 uColor; }; +layout(location = 0) out vec4 o; +void main() { o = vec4(uColor.rgb, 1.0); }"; + + private readonly VulkanContext _ctx; + private readonly VkDeviceApi _api; + + private readonly int _instanceCount; + private readonly uint[] _drawIndexCount; + private readonly uint[] _drawFirstIndex; + private readonly int[] _drawVertexOffset; + private readonly int[] _drawMatSlot; + private readonly Vector4[] _matPC0; + private readonly float[] _matRenderMode; // the game's mode 0-6 (pushed as uMat1.x) + private readonly float[] _matVertexAlpha; + private readonly float[] _matAlbedoHasAlpha; // pushed as uMat1.w + private readonly float[] _matAlphaRef; // per-mode alpha-test reference (GEQUAL), 0 = no test + // Bucket boundaries in the mode-sorted instance list: [0,_overStart) opaque+cutout, + // [_overStart,_addStart) over-blended (WBOIT), [_addStart,_softStart) additive, + // [_softStart,_billStart) soft-edge (drawn twice: depth prepass + WBOIT), + // [_billStart,_instanceCount) foliage billboards. + private readonly int _overStart, _addStart, _softStart, _billStart; + + // Frustum culling: static per-instance world bounding spheres, plus per-frame scratch. The visible + // lists hold indices into the sorted static arrays, ordered (so material-run batching still holds); + // culling is threaded over chunks that compact into disjoint regions of _cullScratch, then merged. + private readonly Vector3[] _instCenter; + private readonly float[] _instRadius; + private readonly byte[] _instKind; + private VkSampler _samplerPoint, _samplerLinear; + private VkImageView[]? _matViews; + private TextureFiltering _filtering = TextureFiltering.Bilinear; + private byte _kindMask = (byte)SceneEntityKind.All; + private bool _frustumCulling = true; + /// Per-instance in-game display distance (units); negative = unlimited. The game's own + /// per-placement cull radius, read straight out of the level's gameplay data. + private readonly float[] _instDisplayDist; + private Vector3 _cameraPosition; + private bool _mobyDistanceCulling; + private readonly Vector4[] _planes = new Vector4[6]; + private readonly int[] _cullScratch; + private readonly int[] _chunkCounts; + private readonly int[] _visibleOpaque; + private readonly int[] _visibleTranslucent; + private readonly int[] _visibleAdditive; + private readonly int[] _visibleSoftEdge; + private readonly int[] _visibleBillboard; + private int _visOpaqueCount, _visTransCount, _visAddCount, _visSoftCount, _visBillCount; + + private uint _width, _height; + private readonly Texture _envCube; + private readonly bool _ownsEnvCube; + + /// Frames the scene pass keeps in flight. + /// + /// Two, so the GPU can be executing the previous frame while this one is being culled and recorded. + /// The wait moved to AFTER the record and the SUBMIT moved to after the swapchain present, which is + /// what actually creates the overlap: everything between those two points runs while the GPU works. + /// A frame's worth of state has to be per-slot for that to be safe - the command buffer, the two + /// uniform buffers it reads, the descriptor set pointing at them, and the colour target, since ImGui + /// samples the PREVIOUS frame's while the GPU writes this one's. + /// + /// Depth, accum and reveal stay shared: only one recorded command buffer is ever SUBMITTED at a + /// time (this frame's record waits for the last one to finish before the next submit), so only one + /// pass ever executes against them. + private const int Frames = 2; + + private VkCommandPool _pool; + private readonly VkCommandBuffer[] _cmds = new VkCommandBuffer[Frames]; + private readonly VkFence[] _fences = new VkFence[Frames]; + private readonly bool[] _pending = new bool[Frames]; + /// Slot being recorded this frame. The other slot holds the frame on screen. + private int _slot; + /// Slot whose colour target is complete and safe for ImGui to sample. + private int _displaySlot; + private bool _everSubmitted; + /// A frame has been recorded and is waiting for . + private bool _recorded; + /// The command buffer currently being recorded. Every vkCmd* helper writes through this. + private VkCommandBuffer _cmd; + private VkBuffer _vertexBuffer; private VkDeviceMemory _vbMemory; + private VkBuffer _indexBuffer; private VkDeviceMemory _ibMemory; + private readonly VkBuffer[] _uniformBuffers = new VkBuffer[Frames]; + private readonly VkDeviceMemory[] _ubMemories = new VkDeviceMemory[Frames]; + private readonly void*[] _ubMappings = new void*[Frames]; + private readonly VkBuffer[] _lightBuffers = new VkBuffer[Frames]; + private readonly VkDeviceMemory[] _lightMemories = new VkDeviceMemory[Frames]; + private readonly void*[] _lightMappings = new void*[Frames]; + private VkBuffer _transformBuffer; private VkDeviceMemory _tbMemory; private void* _tbMapped; + private VkDescriptorSetLayout _descLayout; private VkDescriptorSetLayout _matSetLayout; private VkDescriptorSetLayout _resolveSetLayout; + private VkDescriptorPool _descPool; private readonly VkDescriptorSet[] _descSets = new VkDescriptorSet[Frames]; private VkDescriptorSet _resolveSet; + /// Set 0 for the slot being recorded. + private VkDescriptorSet _descSet; + private VkSampler _sampler; + private VkImageView _envCubeView; + private VkImageView[] _texViews = []; + private VkDescriptorSet[] _matSets = []; + private VkRenderPass _rpOpaque, _rpAccum, _rpResolve; + private VkShaderModule _vs, _fsOpaque, _fsAccum, _fsAdditive, _resolveVs, _resolveFs, _volumeVs, _volumeFs; + private VkShaderModule _outlineVs, _outlineFs; + private VkPipelineLayout _layout, _resolveLayout, _volumeLayout, _outlineLayout; + private VkPipeline _pipelineOpaque, _pipelineAccum, _pipelineResolve, _volumePipeline; + private VkPipeline _pipelineSoftEdgeDepth, _pipelineAdditive; + private VkPipeline _pipelineOutlineMask, _pipelineOutlineRim, _pipelineBillboard; + + // GPU picking: a PickTargetSize-square colour+depth target, its own one-shot command buffer and + // fence, and a host-visible buffer the result is copied into. Sized once, never resized - the pick + // window is a fixed number of screen pixels regardless of viewport size. + private const uint PickTargetSize = 8; + /// Side, in screen pixels, of the square window around the cursor a pick can resolve to. + /// Clicks near a thin silhouette can land a pixel or two off, so the nearest hit inside this window + /// wins rather than demanding the exact pixel under the cursor. + public const int PickWindowPixels = 5; + /// Returned by when nothing was under the cursor. + public const uint NoHit = uint.MaxValue; + // 5 mat4s - see CreateUniformBuffers for what each slot holds. + private const ulong UboSize = 320; + private VkRenderPass _rpPick; + private VkPipeline _pipelinePick, _pipelinePickVolume, _pipelinePickBillboard; + private VkPipelineLayout _pickLayout; + private VkShaderModule _pickVs, _pickVolumeVs, _pickFs, _pickBillboardVs; + private VkImage _pickImage; private VkDeviceMemory _pickMemory; private VkImageView _pickView; + private VkImage _pickDepthImage; private VkDeviceMemory _pickDepthMemory; private VkImageView _pickDepthView; + private VkFramebuffer _fbPick; + private VkBuffer _pickReadback; private VkDeviceMemory _pickReadbackMemory; private void* _pickReadbackMapped; + private VkCommandBuffer _pickCmd; private VkFence _pickFence; + private readonly uint[] _instPickId; + private readonly Vector4[] _pickPlanes = new Vector4[6]; + + // Debug line list, refilled every frame. The buffer grows to the high-water mark and is never + // shrunk; a few thousand segments is nothing next to the scene. + private const int DebugLineFloats = 7; // pos xyz + rgba + private VkPipeline _pipelineDebugLines; + private VkShaderModule _debugLineVs, _debugLineFs; + private VkBuffer _debugLineBuffer; private VkDeviceMemory _debugLineMemory; private void* _debugLineMapped; + private int _debugLineCapacity; + private int _debugVertexCount; + + /// Colour the scene is cleared to before anything is drawn. + public Vector4 ClearColour = new(0.05f, 0.06f, 0.09f, 1f); + private VkShaderModule _billboardVs, _billboardFs; + + // Which scene instances belong to which entity, so the selection outline (and live transform + // edits) can address one entity's draws without rescanning the whole instance list every frame. + private readonly Dictionary _ownerInstances = new(ReferenceEqualityComparer.Instance); + private object? _selected; + /// Lit shading on/off. Unlit still parallax-offsets and alpha-tests, so silhouettes and + /// cutouts are unchanged - only the lighting, baked or analytic, and the reflections drop out. + private bool _lit = true; + private Vector4 _outlineColor = new(1f, 0.6f, 0.1f, 1f); + private float _outlineThickness = 0.004f; + + // Thin-box edge geometry (a unit-length cross of two thin quads: + // 8 verts, 12 tri indices; thickness-dependent, host-mapped so it rebuilds when the setting changes) + // + the per-frame volume-edge list (one (worldMatrix, colour) per edge, 12 per volume). + private VkBuffer _edgeVertexBuffer; private VkDeviceMemory _edgeVbMemory; private void* _edgeVbMapped; + private VkBuffer _edgeIndexBuffer; private VkDeviceMemory _edgeIbMemory; private uint _edgeIndexCount; + private float _edgeThickness = -1f; + private IReadOnlyList<(Matrix4x4 world, Vector4 color, uint pickId)> _volumes = System.Array.Empty<(Matrix4x4, Vector4, uint)>(); + private int _volumeCount; + + // Size-dependent targets. Colour is per-slot (ImGui reads one while the GPU writes the other); + // depth/accum/reveal are shared, see the Frames comment. + private readonly Texture[] _colorTex = new Texture[Frames]; + private readonly VkImage[] _colorImage = new VkImage[Frames]; + private readonly VkImageView[] _colorView = new VkImageView[Frames]; + private VkImage _depthImage; private VkDeviceMemory _depthMemory; private VkImageView _depthView; + private VkImage _accumImage; private VkDeviceMemory _accumMemory; private VkImageView _accumView; + private VkImage _revealImage; private VkDeviceMemory _revealMemory; private VkImageView _revealView; + private readonly VkFramebuffer[] _fbOpaque = new VkFramebuffer[Frames]; + private readonly VkFramebuffer[] _fbResolve = new VkFramebuffer[Frames]; + private VkFramebuffer _fbAccum; + + private long _submits; private bool _loggedInit; + + /// Draws that survived this frame's culling, across every bucket. + public int VisibleDrawCount => _visOpaqueCount + _visTransCount + _visAddCount + _visSoftCount + _visBillCount; + + /// The Veldrith texture the scene is rendered into - display this in ImGui. + /// The scene image for ImGui to display. This is the LAST COMPLETED frame, not the one + /// being recorded, so the viewport runs one frame behind the camera - the cost of the overlap. + public Texture ColorTexture => _colorTex[_displaySlot]; + + public VulkanRenderer(GraphicsDevice graphicsDevice, List geomVerts, List geomIndices, List materials, List<(int geo, int mat, Matrix4x4 world, Vector4 sphere, object owner, float displayDistance, uint pickId)> instances, Texture? envCube, uint width, uint height) + { + _ctx = new VulkanContext(graphicsDevice); + _api = _ctx.DeviceApi; + _instanceCount = instances.Count; + _width = Math.Max(width, 1u); + _height = Math.Max(height, 1u); + // A scene without an environment cubemap is legitimate - the asset preview shows a placeholder + // before any level is loaded, and AssetManager (the usual source) does not exist yet. Fall back + // to a 1x1 mid-grey cube: reflections are gated on EnvironmentIntensity, which is 0 without a + // level, so it contributes nothing and merely keeps the descriptor bound. + _ownsEnvCube = envCube == null; + _envCube = envCube ?? CreateFallbackCubemap(graphicsDevice); + + _matPC0 = new Vector4[materials.Count]; + _matRenderMode = new float[materials.Count]; + _matVertexAlpha = new float[materials.Count]; + _matAlbedoHasAlpha = new float[materials.Count]; + _matAlphaRef = new float[materials.Count]; + for (int i = 0; i < materials.Count; i++) + { + var mode = (GameRenderMode)(byte)materials[i].GameRenderMode; + // Alpha-test references are HARDCODED per mode by the engine, not per material - the old + // ShaderMetadata 0x20 "alphaClip" turned out to be an RGB parameter, not a threshold + // (dev/chatgpt-eboot-{4,5}.txt). Cutout clips at 128/255; the blended paths clip at 4/255 + // purely to skip fully-transparent texels. + float alphaRef = mode switch + { + // Foliage: the source shaders classify as Blended, but that classification exists to + // route foliage into the game's second (polygon-offset) pass, not because a sprite card + // needs real blending. The cards are alpha-cut leaves, so they clip like Cutout and + // write depth; blending them order-independently instead just makes them look ghosted. + _ when materials[i].IsBillboard > 0.5f => 128f / 255f, + GameRenderMode.Cutout => 128f / 255f, + GameRenderMode.SoftEdge => 4f / 255f, // pass 2 (the depth prepass uses 128/255, below) + GameRenderMode.Overlay or GameRenderMode.Additive => 4f / 255f, + _ => 0f, + }; + _matPC0[i] = new Vector4(materials[i].HasBaked, materials[i].ParallaxScale, materials[i].ParallaxBias, alphaRef); + _matRenderMode[i] = materials[i].GameRenderMode; + _matVertexAlpha[i] = materials[i].UsesVertexAlpha; + _matAlbedoHasAlpha[i] = materials[i].AlbedoHasAlphaChannel; + _matAlphaRef[i] = alphaRef; + } + + int geoCount = geomVerts.Count; + var baseVertex = new int[geoCount]; + var firstIndex = new uint[geoCount]; + var idxCount = new uint[geoCount]; + int vTotal = 0; uint iTotal = 0; + for (int g = 0; g < geoCount; g++) + { + baseVertex[g] = vTotal; + firstIndex[g] = iTotal; + idxCount[g] = (uint)geomIndices[g].Length; + vTotal += geomVerts[g].Length / VulkanSceneCapture.FloatsPerVertex; + iTotal += (uint)geomIndices[g].Length; + } + var mergedVerts = new float[vTotal * VulkanSceneCapture.FloatsPerVertex]; + var mergedIdx = new uint[iTotal]; + int vo = 0; uint io = 0; + for (int g = 0; g < geoCount; g++) + { + var p = geomVerts[g]; + Array.Copy(p, 0, mergedVerts, vo, p.Length); vo += p.Length; + var ix = geomIndices[g]; + Array.Copy(ix, 0, mergedIdx, (int)io, ix.Length); io += (uint)ix.Length; + } + + // Bucket each material by the game's render mode, then order instances bucket-major (and by + // material within a bucket, so each material's descriptor set still binds once per run): + // 0 OPAQUE modes Opaque + Cutout (+ Soft-Edge's depth prepass, drawn from bucket 3) + // 1 OVER modes Overlay/Scunge/Blended + Soft-Edge colour pass -> WBOIT accumulate + // 2 ADDITIVE mode Additive (SrcAlpha/One) -> its own additive pass + // 3 SOFTEDGE mode Soft-Edge: drawn TWICE (depth prepass in the opaque pass, then WBOIT) + // 4 BILLBOARD foliage sprite cards -> the billboard vertex shader, alpha-tested, opaque pass + var matBucket = new int[materials.Count]; + for (int i = 0; i < materials.Count; i++) + matBucket[i] = materials[i].IsBillboard > 0.5f ? 4 : (GameRenderMode)(byte)materials[i].GameRenderMode switch + { + GameRenderMode.Opaque or GameRenderMode.Cutout => 0, + GameRenderMode.Additive => 2, + GameRenderMode.SoftEdge => 3, + _ => 1, // Overlay, Scunge, Blended + }; + var order = new int[_instanceCount]; + for (int i = 0; i < _instanceCount; i++) order[i] = i; + Array.Sort(order, (a, b) => + { + int ba = matBucket[instances[a].mat], bb = matBucket[instances[b].mat]; + return ba != bb ? ba - bb : instances[a].mat.CompareTo(instances[b].mat); + }); + + _drawIndexCount = new uint[_instanceCount]; + _drawFirstIndex = new uint[_instanceCount]; + _drawVertexOffset = new int[_instanceCount]; + _drawMatSlot = new int[_instanceCount]; + _instCenter = new Vector3[_instanceCount]; + _instRadius = new float[_instanceCount]; + _instKind = new byte[_instanceCount]; + _instDisplayDist = new float[_instanceCount]; + _instPickId = new uint[_instanceCount]; + var worlds = new Matrix4x4[_instanceCount]; + int overStart = _instanceCount, addStart = _instanceCount, softStart = _instanceCount, billStart = _instanceCount; + var ownerRuns = new Dictionary>(ReferenceEqualityComparer.Instance); + for (int i = 0; i < _instanceCount; i++) + { + var (g, mat, world, sphere, owner, displayDistance, pickId) = instances[order[i]]; + if (owner is not null) + { + if (!ownerRuns.TryGetValue(owner, out var run)) ownerRuns[owner] = run = new List(1); + run.Add(i); + } + _drawIndexCount[i] = idxCount[g]; + _drawFirstIndex[i] = firstIndex[g]; + _drawVertexOffset[i] = baseVertex[g]; + _drawMatSlot[i] = mat; + worlds[i] = world; + // The game's own world bounding sphere (Entity.WorldBoundingSphere): xyz centre, w radius. + _instCenter[i] = new Vector3(sphere.X, sphere.Y, sphere.Z); + _instRadius[i] = sphere.W; + // Resolved from the owner rather than passed in: the owner is already here, and widening the + // instance tuple again would make every caller carry a field only the level view can fill. + _instKind[i] = (byte)(owner switch + { + Scene.EntityMoby => SceneEntityKind.Moby, + Scene.EntityTie => SceneEntityKind.Tie, + Scene.EntityUFrag => SceneEntityKind.UFrag, + Scene.EntityFoliage => SceneEntityKind.Foliage, + _ => SceneEntityKind.Other, + }); + _instDisplayDist[i] = displayDistance; + _instPickId[i] = pickId; + int bucket = matBucket[mat]; + if (bucket >= 1 && i < overStart) overStart = i; + if (bucket >= 2 && i < addStart) addStart = i; + if (bucket >= 3 && i < softStart) softStart = i; + if (bucket >= 4 && i < billStart) billStart = i; + } + foreach (var (owner, run) in ownerRuns) _ownerInstances[owner] = [.. run]; + // Empty buckets collapse to the following bucket's start so every range stays well-ordered. + _billStart = billStart; + _softStart = Math.Min(softStart, _billStart); + _addStart = Math.Min(addStart, _softStart); + _overStart = Math.Min(overStart, _addStart); + + // Per-frame culling scratch (no per-frame allocation). + _cullScratch = new int[_instanceCount]; + _visibleOpaque = new int[_instanceCount]; + _visibleTranslucent = new int[_instanceCount]; + _visibleAdditive = new int[_instanceCount]; + _visibleSoftEdge = new int[_instanceCount]; + _visibleBillboard = new int[_instanceCount]; + _chunkCounts = new int[Environment.ProcessorCount]; + + var poolInfo = new VkCommandPoolCreateInfo { flags = VkCommandPoolCreateFlags.ResetCommandBuffer, queueFamilyIndex = _ctx.GraphicsQueueFamilyIndex }; + VkCommandPool pool; Check(_api.vkCreateCommandPool(&poolInfo, &pool), "vkCreateCommandPool"); _pool = pool; + var cbAlloc = new VkCommandBufferAllocateInfo { commandPool = _pool, level = VkCommandBufferLevel.Primary, commandBufferCount = 1 }; + var fenceInfo = new VkFenceCreateInfo(); + for (int f = 0; f < Frames; f++) + { + VkCommandBuffer cmd; Check(_api.vkAllocateCommandBuffers(&cbAlloc, &cmd), "vkAllocateCommandBuffers"); _cmds[f] = cmd; + VkFence fence; Check(_api.vkCreateFence(&fenceInfo, &fence), "vkCreateFence"); _fences[f] = fence; + } + _cmd = _cmds[0]; + + var vsw = System.Diagnostics.Stopwatch.StartNew(); long vt0 = 0; + UploadGeometry(mergedVerts, mergedIdx); + CreateVolumeGeometry(); + UploadTransforms(worlds); + long tUpload = vsw.ElapsedMilliseconds - vt0; vt0 = vsw.ElapsedMilliseconds; + CreateUniformBuffers(); + CreateSampler(); + long tSamplers = vsw.ElapsedMilliseconds - vt0; vt0 = vsw.ElapsedMilliseconds; + // One vkAllocateDescriptorSets + up to 5 vkCreateImageView + one vkUpdateDescriptorSets PER + // material, all synchronous driver calls - isolated because it is the one phase here that + // scales with the LEVEL (material count), not with a fixed viewport/shader cost like the rest. + CreateDescriptors(materials); + long tDescriptors = vsw.ElapsedMilliseconds - vt0; vt0 = vsw.ElapsedMilliseconds; + CreateRenderPasses(); + CreatePipelines(); + long tPipelines = vsw.ElapsedMilliseconds - vt0; vt0 = vsw.ElapsedMilliseconds; + CreateTargets(graphicsDevice); + CreatePickResources(); + long tTargets = vsw.ElapsedMilliseconds - vt0; + long buildMs = vsw.ElapsedMilliseconds; + // Command buffer is recorded per-frame in Frame() (only the visible, frustum-culled draws). + + // Per-mode material census: shows whether each game render mode actually reaches the renderer, + // and how many of its materials take opacity from the vertex alpha rather than the albedo. + var modeCensus = new int[7]; + var modeVertexAlpha = new int[7]; + foreach (var m in materials) + { + int mi = Math.Clamp((int)m.GameRenderMode, 0, 6); + modeCensus[mi]++; + if (m.UsesVertexAlpha > 0.5f) modeVertexAlpha[mi]++; + } + string[] modeNames = ["Opaque", "Overlay", "Additive", "Scunge", "Cutout", "SoftEdge", "Blended"]; + var census = string.Join(", ", Enumerable.Range(0, 7) + .Where(m => modeCensus[m] > 0) + .Select(m => $"{modeNames[m]}={modeCensus[m]}" + (modeVertexAlpha[m] > 0 ? $"({modeVertexAlpha[m]} vtxA)" : ""))); + Console.WriteLine($"[VkRenderer] init OK - {geoCount} geometries, {materials.Count} materials, {_instanceCount} draws. Buckets: {_overStart} opaque/cutout, {_addStart - _overStart} over-blended, {_softStart - _addStart} additive, {_billStart - _softStart} soft-edge, {_instanceCount - _billStart} foliage. Materials by mode: {census}. Into a {_width}x{_height} texture. Built in {buildMs}ms (upload {tUpload}, samplers/UBOs {tSamplers}, descriptors {tDescriptors}, pipelines {tPipelines}, targets {tTargets})."); + } + + private void UploadGeometry(float[] mergedVerts, uint[] mergedIdx) + { + ulong vSize = (ulong)(mergedVerts.Length * sizeof(float)); + (_vertexBuffer, _vbMemory) = CreateBuffer(vSize, VkBufferUsageFlags.VertexBuffer, VkMemoryPropertyFlags.HostVisible | VkMemoryPropertyFlags.HostCoherent); + void* vp; Check(_api.vkMapMemory(_vbMemory, 0, vSize, 0, &vp), "vkMapMemory(vb)"); + fixed (float* s = mergedVerts) Buffer.MemoryCopy(s, vp, vSize, vSize); + _api.vkUnmapMemory(_vbMemory); + + ulong iSize = (ulong)(mergedIdx.Length * sizeof(uint)); + (_indexBuffer, _ibMemory) = CreateBuffer(iSize, VkBufferUsageFlags.IndexBuffer, VkMemoryPropertyFlags.HostVisible | VkMemoryPropertyFlags.HostCoherent); + void* ip; Check(_api.vkMapMemory(_ibMemory, 0, iSize, 0, &ip), "vkMapMemory(ib)"); + fixed (uint* s = mergedIdx) Buffer.MemoryCopy(s, ip, iSize, iSize); + _api.vkUnmapMemory(_ibMemory); + } + + private void CreateVolumeGeometry() + { + // Two quads (thin in Y, thin in Z) -> a unit-length "+" cross section, 4 verts + 2 tris each. + uint[] idx = { 0, 1, 2, 0, 2, 3, 4, 5, 6, 4, 6, 7 }; + _edgeIndexCount = (uint)idx.Length; + ulong iSize = (ulong)(idx.Length * sizeof(uint)); + (_edgeIndexBuffer, _edgeIbMemory) = CreateBuffer(iSize, VkBufferUsageFlags.IndexBuffer, VkMemoryPropertyFlags.HostVisible | VkMemoryPropertyFlags.HostCoherent); + void* ip; Check(_api.vkMapMemory(_edgeIbMemory, 0, iSize, 0, &ip), "vkMapMemory(edgeIb)"); + fixed (uint* s = idx) Buffer.MemoryCopy(s, ip, iSize, iSize); + _api.vkUnmapMemory(_edgeIbMemory); + + // Vertex buffer stays host-mapped so WriteEdgeVertices can rebuild it when the thickness changes. + ulong vSize = 8 * 3 * sizeof(float); + (_edgeVertexBuffer, _edgeVbMemory) = CreateBuffer(vSize, VkBufferUsageFlags.VertexBuffer, VkMemoryPropertyFlags.HostVisible | VkMemoryPropertyFlags.HostCoherent); + void* vp; Check(_api.vkMapMemory(_edgeVbMemory, 0, vSize, 0, &vp), "vkMapMemory(edgeVb)"); _edgeVbMapped = vp; + } + + // Rebuilds the unit edge's 8 vertices for a given world-space thickness (t = thickness/2), matching + // Length +/-0.5 along X, thin +/-t on Y (quad 1) and Z (quad 2). + private void WriteEdgeVertices(float thickness) + { + // Only runs when the wire-thickness setting changes, so the drain is free. + WaitForPendingFrames(); + float t = MathF.Max(thickness, 0.001f) * 0.5f; + float* p = (float*)_edgeVbMapped; + // quad 1 (thin in Y) + p[0] = -0.5f; p[1] = -t; p[2] = 0f; p[3] = 0.5f; p[4] = -t; p[5] = 0f; + p[6] = 0.5f; p[7] = t; p[8] = 0f; p[9] = -0.5f; p[10] = t; p[11] = 0f; + // quad 2 (thin in Z) + p[12] = -0.5f; p[13] = 0f; p[14] = -t; p[15] = 0.5f; p[16] = 0f; p[17] = -t; + p[18] = 0.5f; p[19] = 0f; p[20] = t; p[21] = -0.5f; p[22] = 0f; p[23] = t; + _edgeThickness = thickness; + } + + private void UploadTransforms(Matrix4x4[] worlds) + { + ulong size = (ulong)(Math.Max(worlds.Length, 1) * 64); + (_transformBuffer, _tbMemory) = CreateBuffer(size, VkBufferUsageFlags.StorageBuffer, VkMemoryPropertyFlags.HostVisible | VkMemoryPropertyFlags.HostCoherent); + // Stays mapped for the lifetime of the renderer: moving an entity in the editor rewrites its + // matrices here in place, which is what lets a gizmo edit show up without re-recording anything. + void* p; Check(_api.vkMapMemory(_tbMemory, 0, size, 0, &p), "vkMapMemory(tb)"); _tbMapped = p; + var dst = (Matrix4x4*)p; + for (int i = 0; i < worlds.Length; i++) dst[i] = worlds[i]; + } + + private void CreateUniformBuffers() + { + // 5 * mat4: viewProj, view, proj, the pick window's view-projection, and the pick window's + // projection-only (for billboards, which need the windowing folded into just the projection + // step - see PickBillboardVertexGlsl). The lit and outline shaders only declare the first, the + // billboard shader three, the generic pick shaders four, the billboard pick shader all five - + // a shader may declare a prefix of a UBO's contents. + // One set per in-flight frame: these are written while the PREVIOUS frame is still reading its + // own copy on the GPU. + ulong lightSize = (ulong)sizeof(LightData); + for (int f = 0; f < Frames; f++) + { + (_uniformBuffers[f], _ubMemories[f]) = CreateBuffer(UboSize, VkBufferUsageFlags.UniformBuffer, VkMemoryPropertyFlags.HostVisible | VkMemoryPropertyFlags.HostCoherent); + void* p; Check(_api.vkMapMemory(_ubMemories[f], 0, UboSize, 0, &p), "vkMapMemory(ub)"); _ubMappings[f] = p; + var ident = (Matrix4x4*)p; + ident[0] = ident[1] = ident[2] = ident[3] = ident[4] = Matrix4x4.Identity; + + (_lightBuffers[f], _lightMemories[f]) = CreateBuffer(lightSize, VkBufferUsageFlags.UniformBuffer, VkMemoryPropertyFlags.HostVisible | VkMemoryPropertyFlags.HostCoherent); + void* lp; Check(_api.vkMapMemory(_lightMemories[f], 0, lightSize, 0, &lp), "vkMapMemory(light)"); _lightMappings[f] = lp; + *(LightData*)lp = default; + } + } + + // Both filtering modes, created up front. Game textures always tile, so only the filter differs; + // switching modes rewrites the material descriptor sets rather than rebuilding anything. + private void CreateSampler() + { + _samplerLinear = CreateWrapSampler(VkFilter.Linear, VkSamplerMipmapMode.Linear); + _samplerPoint = CreateWrapSampler(VkFilter.Nearest, VkSamplerMipmapMode.Nearest); + _sampler = _filtering == TextureFiltering.Point ? _samplerPoint : _samplerLinear; + } + + private VkSampler CreateWrapSampler(VkFilter filter, VkSamplerMipmapMode mipmapMode) + { + var info = new VkSamplerCreateInfo + { + magFilter = filter, minFilter = filter, mipmapMode = mipmapMode, + addressModeU = VkSamplerAddressMode.Repeat, addressModeV = VkSamplerAddressMode.Repeat, addressModeW = VkSamplerAddressMode.Repeat, + minLod = 0f, maxLod = 16f, mipLodBias = 0f, maxAnisotropy = 1f, + }; + VkSampler s; Check(_api.vkCreateSampler(&info, &s), "vkCreateSampler"); return s; + } + + /// Switches how the scene's textures are sampled. Only rewrites the material descriptor + /// sets, which is safe with no extra synchronisation because Frame() waits its own fence before + /// returning, so no submitted work is ever still reading them. + private void ApplyFiltering(TextureFiltering filtering) + { + if (filtering == _filtering) return; + // Rewrites every material descriptor set, which an in-flight frame is bound to. + WaitForPendingFrames(); + _filtering = filtering; + _sampler = filtering == TextureFiltering.Point ? _samplerPoint : _samplerLinear; + WriteMaterialSets(); + } + + /// Points every material's five samplers at and its stored views. + private void WriteMaterialSets() + { + if (_matSets == null || _matViews == null) return; + + VkDescriptorImageInfo* imgs = stackalloc VkDescriptorImageInfo[TexPerMaterial]; + VkWriteDescriptorSet* w = stackalloc VkWriteDescriptorSet[TexPerMaterial]; + for (int i = 0; i < _matSets.Length; i++) + { + for (uint bnd = 0; bnd < TexPerMaterial; bnd++) + { + imgs[bnd] = new VkDescriptorImageInfo { sampler = _sampler, imageView = _matViews[i * TexPerMaterial + bnd], imageLayout = VkImageLayout.ShaderReadOnlyOptimal }; + w[bnd] = new VkWriteDescriptorSet { dstSet = _matSets[i], dstBinding = bnd, descriptorCount = 1, descriptorType = VkDescriptorType.CombinedImageSampler, pImageInfo = &imgs[bnd] }; + } + _api.vkUpdateDescriptorSets(TexPerMaterial, w, 0, null); + } + } + + private void CreateDescriptors(List materials) + { + VkDescriptorSetLayoutBinding* set0 = stackalloc VkDescriptorSetLayoutBinding[4]; + set0[0] = new VkDescriptorSetLayoutBinding { binding = 0, descriptorType = VkDescriptorType.UniformBuffer, descriptorCount = 1, stageFlags = VkShaderStageFlags.Vertex }; + set0[1] = new VkDescriptorSetLayoutBinding { binding = 1, descriptorType = VkDescriptorType.StorageBuffer, descriptorCount = 1, stageFlags = VkShaderStageFlags.Vertex }; + set0[2] = new VkDescriptorSetLayoutBinding { binding = 2, descriptorType = VkDescriptorType.UniformBuffer, descriptorCount = 1, stageFlags = VkShaderStageFlags.Fragment }; + set0[3] = new VkDescriptorSetLayoutBinding { binding = 3, descriptorType = VkDescriptorType.CombinedImageSampler, descriptorCount = 1, stageFlags = VkShaderStageFlags.Fragment }; + var set0Info = new VkDescriptorSetLayoutCreateInfo { bindingCount = 4, pBindings = set0 }; + VkDescriptorSetLayout dl0; Check(_api.vkCreateDescriptorSetLayout(&set0Info, &dl0), "vkCreateDescriptorSetLayout(0)"); _descLayout = dl0; + + VkDescriptorSetLayoutBinding* set1 = stackalloc VkDescriptorSetLayoutBinding[TexPerMaterial]; + for (uint i = 0; i < TexPerMaterial; i++) + set1[i] = new VkDescriptorSetLayoutBinding { binding = i, descriptorType = VkDescriptorType.CombinedImageSampler, descriptorCount = 1, stageFlags = VkShaderStageFlags.Fragment }; + var set1Info = new VkDescriptorSetLayoutCreateInfo { bindingCount = TexPerMaterial, pBindings = set1 }; + VkDescriptorSetLayout dl1; Check(_api.vkCreateDescriptorSetLayout(&set1Info, &dl1), "vkCreateDescriptorSetLayout(1)"); _matSetLayout = dl1; + + VkDescriptorSetLayoutBinding* setR = stackalloc VkDescriptorSetLayoutBinding[2]; + setR[0] = new VkDescriptorSetLayoutBinding { binding = 0, descriptorType = VkDescriptorType.CombinedImageSampler, descriptorCount = 1, stageFlags = VkShaderStageFlags.Fragment }; + setR[1] = new VkDescriptorSetLayoutBinding { binding = 1, descriptorType = VkDescriptorType.CombinedImageSampler, descriptorCount = 1, stageFlags = VkShaderStageFlags.Fragment }; + var setRInfo = new VkDescriptorSetLayoutCreateInfo { bindingCount = 2, pBindings = setR }; + VkDescriptorSetLayout dlR; Check(_api.vkCreateDescriptorSetLayout(&setRInfo, &dlR), "vkCreateDescriptorSetLayout(resolve)"); _resolveSetLayout = dlR; + + int nMat = materials.Count; + VkDescriptorPoolSize* sizes = stackalloc VkDescriptorPoolSize[3]; + // Set 0 exists once per in-flight frame, so its descriptors are counted Frames times. + sizes[0] = new VkDescriptorPoolSize { type = VkDescriptorType.UniformBuffer, descriptorCount = 2 * Frames }; + sizes[1] = new VkDescriptorPoolSize { type = VkDescriptorType.StorageBuffer, descriptorCount = Frames }; + sizes[2] = new VkDescriptorPoolSize { type = VkDescriptorType.CombinedImageSampler, descriptorCount = (uint)(TexPerMaterial * nMat + Frames + 2) }; // +cube per set0 +resolve(accum,reveal) + var poolInfo = new VkDescriptorPoolCreateInfo { maxSets = (uint)(Frames + nMat + 1), poolSizeCount = 3, pPoolSizes = sizes }; + VkDescriptorPool dp; Check(_api.vkCreateDescriptorPool(&poolInfo, &dp), "vkCreateDescriptorPool"); _descPool = dp; + + VkImage cubeImage = _ctx.BackendInfo.GetVkImage(_envCube); + var cubeViewInfo = new VkImageViewCreateInfo { image = cubeImage, viewType = VkImageViewType.ImageCube, format = ColorFormat, components = default, subresourceRange = new VkImageSubresourceRange { aspectMask = VkImageAspectFlags.Color, baseMipLevel = 0, levelCount = Math.Max(1u, _envCube.MipLevels), baseArrayLayer = 0, layerCount = 6 } }; + VkImageView cubeView; Check(_api.vkCreateImageView(&cubeViewInfo, &cubeView), "vkCreateImageView(cube)"); _envCubeView = cubeView; + + // One set 0 per in-flight frame. Only the two uniform buffers differ between them; the + // transform SSBO and the cubemap are shared (see UpdateEntityTransforms for why the SSBO can be). + var ssboInfo = new VkDescriptorBufferInfo { buffer = _transformBuffer, offset = 0, range = Vortice.Vulkan.Vulkan.VK_WHOLE_SIZE }; + var cubeInfo = new VkDescriptorImageInfo { sampler = _sampler, imageView = _envCubeView, imageLayout = VkImageLayout.ShaderReadOnlyOptimal }; + for (int f = 0; f < Frames; f++) + { + VkDescriptorSetLayout l0 = _descLayout; + var alloc0 = new VkDescriptorSetAllocateInfo { descriptorPool = _descPool, descriptorSetCount = 1, pSetLayouts = &l0 }; + VkDescriptorSet ds0; Check(_api.vkAllocateDescriptorSets(&alloc0, &ds0), "vkAllocateDescriptorSets(0)"); _descSets[f] = ds0; + var uboInfo = new VkDescriptorBufferInfo { buffer = _uniformBuffers[f], offset = 0, range = UboSize }; + var lightInfo = new VkDescriptorBufferInfo { buffer = _lightBuffers[f], offset = 0, range = (ulong)sizeof(LightData) }; + VkWriteDescriptorSet* w0 = stackalloc VkWriteDescriptorSet[4]; + w0[0] = new VkWriteDescriptorSet { dstSet = ds0, dstBinding = 0, descriptorCount = 1, descriptorType = VkDescriptorType.UniformBuffer, pBufferInfo = &uboInfo }; + w0[1] = new VkWriteDescriptorSet { dstSet = ds0, dstBinding = 1, descriptorCount = 1, descriptorType = VkDescriptorType.StorageBuffer, pBufferInfo = &ssboInfo }; + w0[2] = new VkWriteDescriptorSet { dstSet = ds0, dstBinding = 2, descriptorCount = 1, descriptorType = VkDescriptorType.UniformBuffer, pBufferInfo = &lightInfo }; + w0[3] = new VkWriteDescriptorSet { dstSet = ds0, dstBinding = 3, descriptorCount = 1, descriptorType = VkDescriptorType.CombinedImageSampler, pImageInfo = &cubeInfo }; + _api.vkUpdateDescriptorSets(4, w0, 0, null); + } + _descSet = _descSets[0]; + + VkDescriptorSetLayout lR = _resolveSetLayout; + var allocR = new VkDescriptorSetAllocateInfo { descriptorPool = _descPool, descriptorSetCount = 1, pSetLayouts = &lR }; + VkDescriptorSet dsR; Check(_api.vkAllocateDescriptorSets(&allocR, &dsR), "vkAllocateDescriptorSets(resolve)"); _resolveSet = dsR; + // Written in CreateTargets once accum/reveal exist (and re-written on resize). + + Texture? fallback = null; + foreach (var m in materials) { fallback = m.Albedo ?? m.Normal ?? m.Props ?? m.LightColour ?? m.LightDir; if (fallback != null) break; } + if (fallback == null) throw new InvalidOperationException("[VkRenderer] Scene has no material textures."); + + var viewOf = new Dictionary(ReferenceEqualityComparer.Instance); + VkImageView ViewFor(Texture? t) + { + t ??= fallback; + if (viewOf.TryGetValue(t, out var existing)) return existing; + VkImage image = _ctx.BackendInfo.GetVkImage(t); + uint mips = Math.Max(1u, t.MipLevels); + var vi = new VkImageViewCreateInfo { image = image, viewType = VkImageViewType.Image2D, format = ColorFormat, components = default, subresourceRange = new VkImageSubresourceRange { aspectMask = VkImageAspectFlags.Color, baseMipLevel = 0, levelCount = mips, baseArrayLayer = 0, layerCount = 1 } }; + VkImageView view; Check(_api.vkCreateImageView(&vi, &view), "vkCreateImageView(tex)"); + viewOf[t] = view; + return view; + } + + _matSets = new VkDescriptorSet[nMat]; + // Kept so the sets can be rewritten when the filtering setting changes without re-resolving + // every texture back to its view. + _matViews = new VkImageView[nMat * TexPerMaterial]; + for (int i = 0; i < nMat; i++) + { + var m = materials[i]; + VkImageView* v = stackalloc VkImageView[TexPerMaterial] { ViewFor(m.Albedo), ViewFor(m.Normal), ViewFor(m.Props), ViewFor(m.LightColour), ViewFor(m.LightDir) }; + for (int bnd = 0; bnd < TexPerMaterial; bnd++) _matViews[i * TexPerMaterial + bnd] = v[bnd]; + VkDescriptorSetLayout l1 = _matSetLayout; + var alloc1 = new VkDescriptorSetAllocateInfo { descriptorPool = _descPool, descriptorSetCount = 1, pSetLayouts = &l1 }; + VkDescriptorSet ds; Check(_api.vkAllocateDescriptorSets(&alloc1, &ds), "vkAllocateDescriptorSets(mat)"); _matSets[i] = ds; + VkDescriptorImageInfo* imgs = stackalloc VkDescriptorImageInfo[TexPerMaterial]; + VkWriteDescriptorSet* w = stackalloc VkWriteDescriptorSet[TexPerMaterial]; + for (uint bnd = 0; bnd < TexPerMaterial; bnd++) + { + imgs[bnd] = new VkDescriptorImageInfo { sampler = _sampler, imageView = v[bnd], imageLayout = VkImageLayout.ShaderReadOnlyOptimal }; + w[bnd] = new VkWriteDescriptorSet { dstSet = ds, dstBinding = bnd, descriptorCount = 1, descriptorType = VkDescriptorType.CombinedImageSampler, pImageInfo = &imgs[bnd] }; + } + _api.vkUpdateDescriptorSets(TexPerMaterial, w, 0, null); + } + _texViews = [.. viewOf.Values]; + } + + private VkRenderPass MakeColorDepthPass(bool depthWrites, VkImageLayout colorFinal) + { + VkAttachmentDescription* a = stackalloc VkAttachmentDescription[2]; + a[0] = new VkAttachmentDescription { format = ColorFormat, samples = VkSampleCountFlags.Count1, loadOp = VkAttachmentLoadOp.Clear, storeOp = VkAttachmentStoreOp.Store, stencilLoadOp = VkAttachmentLoadOp.DontCare, stencilStoreOp = VkAttachmentStoreOp.DontCare, initialLayout = VkImageLayout.Undefined, finalLayout = colorFinal }; + a[1] = new VkAttachmentDescription { format = DepthFormat, samples = VkSampleCountFlags.Count1, loadOp = VkAttachmentLoadOp.Clear, storeOp = depthWrites ? VkAttachmentStoreOp.Store : VkAttachmentStoreOp.DontCare, stencilLoadOp = VkAttachmentLoadOp.Clear, stencilStoreOp = VkAttachmentStoreOp.Store, initialLayout = VkImageLayout.Undefined, finalLayout = VkImageLayout.DepthStencilAttachmentOptimal }; + var colorRef = new VkAttachmentReference { attachment = 0, layout = VkImageLayout.ColorAttachmentOptimal }; + var depthRef = new VkAttachmentReference { attachment = 1, layout = VkImageLayout.DepthStencilAttachmentOptimal }; + var subpass = new VkSubpassDescription { pipelineBindPoint = VkPipelineBindPoint.Graphics, colorAttachmentCount = 1, pColorAttachments = &colorRef, pDepthStencilAttachment = &depthRef }; + VkSubpassDependency* deps = stackalloc VkSubpassDependency[2]; + deps[0] = new VkSubpassDependency { srcSubpass = Vortice.Vulkan.Vulkan.VK_SUBPASS_EXTERNAL, dstSubpass = 0, srcStageMask = VkPipelineStageFlags.FragmentShader, srcAccessMask = VkAccessFlags.ShaderRead, dstStageMask = VkPipelineStageFlags.ColorAttachmentOutput | VkPipelineStageFlags.EarlyFragmentTests, dstAccessMask = VkAccessFlags.ColorAttachmentWrite | VkAccessFlags.DepthStencilAttachmentWrite }; + deps[1] = new VkSubpassDependency { srcSubpass = 0, dstSubpass = Vortice.Vulkan.Vulkan.VK_SUBPASS_EXTERNAL, srcStageMask = VkPipelineStageFlags.ColorAttachmentOutput | VkPipelineStageFlags.LateFragmentTests, srcAccessMask = VkAccessFlags.ColorAttachmentWrite | VkAccessFlags.DepthStencilAttachmentWrite, dstStageMask = VkPipelineStageFlags.FragmentShader | VkPipelineStageFlags.EarlyFragmentTests, dstAccessMask = VkAccessFlags.ShaderRead | VkAccessFlags.DepthStencilAttachmentRead }; + var info = new VkRenderPassCreateInfo { attachmentCount = 2, pAttachments = a, subpassCount = 1, pSubpasses = &subpass, dependencyCount = 2, pDependencies = deps }; + VkRenderPass rp; Check(_api.vkCreateRenderPass(&info, &rp), "vkCreateRenderPass"); return rp; + } + + private void CreateRenderPasses() + { + // Opaque: clears + writes colour and depth; colour left as attachment (resolve writes it later). + _rpOpaque = MakeColorDepthPass(depthWrites: true, colorFinal: VkImageLayout.ColorAttachmentOptimal); + + // Accumulate: two colour targets (accum, reveal) + read-only depth from the opaque pass. + VkAttachmentDescription* a = stackalloc VkAttachmentDescription[3]; + a[0] = new VkAttachmentDescription { format = AccumFormat, samples = VkSampleCountFlags.Count1, loadOp = VkAttachmentLoadOp.Clear, storeOp = VkAttachmentStoreOp.Store, stencilLoadOp = VkAttachmentLoadOp.DontCare, stencilStoreOp = VkAttachmentStoreOp.DontCare, initialLayout = VkImageLayout.Undefined, finalLayout = VkImageLayout.ShaderReadOnlyOptimal }; + a[1] = new VkAttachmentDescription { format = RevealFormat, samples = VkSampleCountFlags.Count1, loadOp = VkAttachmentLoadOp.Clear, storeOp = VkAttachmentStoreOp.Store, stencilLoadOp = VkAttachmentLoadOp.DontCare, stencilStoreOp = VkAttachmentStoreOp.DontCare, initialLayout = VkImageLayout.Undefined, finalLayout = VkImageLayout.ShaderReadOnlyOptimal }; + a[2] = new VkAttachmentDescription { format = DepthFormat, samples = VkSampleCountFlags.Count1, loadOp = VkAttachmentLoadOp.Load, storeOp = VkAttachmentStoreOp.Store, stencilLoadOp = VkAttachmentLoadOp.Load, stencilStoreOp = VkAttachmentStoreOp.Store, initialLayout = VkImageLayout.DepthStencilAttachmentOptimal, finalLayout = VkImageLayout.DepthStencilAttachmentOptimal }; + VkAttachmentReference* colorRefs = stackalloc VkAttachmentReference[2]; + colorRefs[0] = new VkAttachmentReference { attachment = 0, layout = VkImageLayout.ColorAttachmentOptimal }; + colorRefs[1] = new VkAttachmentReference { attachment = 1, layout = VkImageLayout.ColorAttachmentOptimal }; + var depthRefRO = new VkAttachmentReference { attachment = 2, layout = VkImageLayout.DepthStencilReadOnlyOptimal }; + var subA = new VkSubpassDescription { pipelineBindPoint = VkPipelineBindPoint.Graphics, colorAttachmentCount = 2, pColorAttachments = colorRefs, pDepthStencilAttachment = &depthRefRO }; + VkSubpassDependency* depsA = stackalloc VkSubpassDependency[2]; + depsA[0] = new VkSubpassDependency { srcSubpass = Vortice.Vulkan.Vulkan.VK_SUBPASS_EXTERNAL, dstSubpass = 0, srcStageMask = VkPipelineStageFlags.LateFragmentTests, srcAccessMask = VkAccessFlags.DepthStencilAttachmentWrite, dstStageMask = VkPipelineStageFlags.ColorAttachmentOutput | VkPipelineStageFlags.EarlyFragmentTests, dstAccessMask = VkAccessFlags.ColorAttachmentWrite | VkAccessFlags.DepthStencilAttachmentRead }; + depsA[1] = new VkSubpassDependency { srcSubpass = 0, dstSubpass = Vortice.Vulkan.Vulkan.VK_SUBPASS_EXTERNAL, srcStageMask = VkPipelineStageFlags.ColorAttachmentOutput, srcAccessMask = VkAccessFlags.ColorAttachmentWrite, dstStageMask = VkPipelineStageFlags.FragmentShader, dstAccessMask = VkAccessFlags.ShaderRead }; + var infoA = new VkRenderPassCreateInfo { attachmentCount = 3, pAttachments = a, subpassCount = 1, pSubpasses = &subA, dependencyCount = 2, pDependencies = depsA }; + VkRenderPass rpA; Check(_api.vkCreateRenderPass(&infoA, &rpA), "vkCreateRenderPass(accum)"); _rpAccum = rpA; + + // Resolve: load the opaque colour, blend the resolved translucent over it, leave it ShaderRead. + VkAttachmentDescription* rAtt = stackalloc VkAttachmentDescription[2]; + rAtt[0] = new VkAttachmentDescription { format = ColorFormat, samples = VkSampleCountFlags.Count1, loadOp = VkAttachmentLoadOp.Load, storeOp = VkAttachmentStoreOp.Store, stencilLoadOp = VkAttachmentLoadOp.DontCare, stencilStoreOp = VkAttachmentStoreOp.DontCare, initialLayout = VkImageLayout.ColorAttachmentOptimal, finalLayout = VkImageLayout.ShaderReadOnlyOptimal }; + rAtt[1] = new VkAttachmentDescription { format = DepthFormat, samples = VkSampleCountFlags.Count1, loadOp = VkAttachmentLoadOp.Load, storeOp = VkAttachmentStoreOp.DontCare, stencilLoadOp = VkAttachmentLoadOp.Load, stencilStoreOp = VkAttachmentStoreOp.DontCare, initialLayout = VkImageLayout.DepthStencilAttachmentOptimal, finalLayout = VkImageLayout.DepthStencilAttachmentOptimal }; + var cRef = new VkAttachmentReference { attachment = 0, layout = VkImageLayout.ColorAttachmentOptimal }; + var rDepthRef = new VkAttachmentReference { attachment = 1, layout = VkImageLayout.DepthStencilAttachmentOptimal }; + var subR = new VkSubpassDescription { pipelineBindPoint = VkPipelineBindPoint.Graphics, colorAttachmentCount = 1, pColorAttachments = &cRef, pDepthStencilAttachment = &rDepthRef }; + VkSubpassDependency* depsR = stackalloc VkSubpassDependency[2]; + depsR[0] = new VkSubpassDependency { srcSubpass = Vortice.Vulkan.Vulkan.VK_SUBPASS_EXTERNAL, dstSubpass = 0, srcStageMask = VkPipelineStageFlags.ColorAttachmentOutput | VkPipelineStageFlags.LateFragmentTests, srcAccessMask = VkAccessFlags.ColorAttachmentWrite | VkAccessFlags.DepthStencilAttachmentWrite, dstStageMask = VkPipelineStageFlags.FragmentShader | VkPipelineStageFlags.ColorAttachmentOutput | VkPipelineStageFlags.EarlyFragmentTests, dstAccessMask = VkAccessFlags.ShaderRead | VkAccessFlags.ColorAttachmentWrite | VkAccessFlags.DepthStencilAttachmentRead | VkAccessFlags.DepthStencilAttachmentWrite }; + depsR[1] = new VkSubpassDependency { srcSubpass = 0, dstSubpass = Vortice.Vulkan.Vulkan.VK_SUBPASS_EXTERNAL, srcStageMask = VkPipelineStageFlags.ColorAttachmentOutput, srcAccessMask = VkAccessFlags.ColorAttachmentWrite, dstStageMask = VkPipelineStageFlags.FragmentShader, dstAccessMask = VkAccessFlags.ShaderRead }; + var infoR = new VkRenderPassCreateInfo { attachmentCount = 2, pAttachments = rAtt, subpassCount = 1, pSubpasses = &subR, dependencyCount = 2, pDependencies = depsR }; + VkRenderPass rpR; Check(_api.vkCreateRenderPass(&infoR, &rpR), "vkCreateRenderPass(resolve)"); _rpResolve = rpR; + + // Pick: a tiny colour+depth target, cleared to NoHit, left readable so it can be copied out. + VkAttachmentDescription* pk = stackalloc VkAttachmentDescription[2]; + pk[0] = new VkAttachmentDescription { format = ColorFormat, samples = VkSampleCountFlags.Count1, loadOp = VkAttachmentLoadOp.Clear, storeOp = VkAttachmentStoreOp.Store, stencilLoadOp = VkAttachmentLoadOp.DontCare, stencilStoreOp = VkAttachmentStoreOp.DontCare, initialLayout = VkImageLayout.Undefined, finalLayout = VkImageLayout.TransferSrcOptimal }; + pk[1] = new VkAttachmentDescription { format = DepthFormat, samples = VkSampleCountFlags.Count1, loadOp = VkAttachmentLoadOp.Clear, storeOp = VkAttachmentStoreOp.DontCare, stencilLoadOp = VkAttachmentLoadOp.DontCare, stencilStoreOp = VkAttachmentStoreOp.DontCare, initialLayout = VkImageLayout.Undefined, finalLayout = VkImageLayout.DepthStencilAttachmentOptimal }; + var pkColor = new VkAttachmentReference { attachment = 0, layout = VkImageLayout.ColorAttachmentOptimal }; + var pkDepth = new VkAttachmentReference { attachment = 1, layout = VkImageLayout.DepthStencilAttachmentOptimal }; + var subP = new VkSubpassDescription { pipelineBindPoint = VkPipelineBindPoint.Graphics, colorAttachmentCount = 1, pColorAttachments = &pkColor, pDepthStencilAttachment = &pkDepth }; + var depP = new VkSubpassDependency { srcSubpass = 0, dstSubpass = Vortice.Vulkan.Vulkan.VK_SUBPASS_EXTERNAL, srcStageMask = VkPipelineStageFlags.ColorAttachmentOutput, srcAccessMask = VkAccessFlags.ColorAttachmentWrite, dstStageMask = VkPipelineStageFlags.Transfer, dstAccessMask = VkAccessFlags.TransferRead }; + var infoP = new VkRenderPassCreateInfo { attachmentCount = 2, pAttachments = pk, subpassCount = 1, pSubpasses = &subP, dependencyCount = 1, pDependencies = &depP }; + VkRenderPass rpP; Check(_api.vkCreateRenderPass(&infoP, &rpP), "vkCreateRenderPass(pick)"); _rpPick = rpP; + } + + // Compiled SPIR-V, cached across renderer instances. Every shader here is a compile-time constant, + // so the same handful of sources are compiled over and over: the asset preview builds a whole + // renderer each time the selection changes, and running all 17 shaders back through glslang for + // that was the bulk of the freeze it caused. Keyed by source + stage, so it stays correct if a + // source is ever built at runtime. + private static readonly Dictionary<(string, ShaderStages), byte[]> SpirvCache = new(); + + /// Every shader this renderer compiles, as (source, stage). The sources are compile-time + /// constants, so the SPIR-V for all of them is known before any level exists. + private static (string Source, ShaderStages Stage)[] AllShaders => + [ + (VertexGlsl, ShaderStages.Vertex), + (FragmentOpaqueGlsl, ShaderStages.Fragment), + (FragmentAccumGlsl, ShaderStages.Fragment), + (FragmentAdditiveGlsl, ShaderStages.Fragment), + (ResolveVertexGlsl, ShaderStages.Vertex), + (ResolveFragmentGlsl, ShaderStages.Fragment), + (DebugLineVertexGlsl, ShaderStages.Vertex), + (DebugLineFragmentGlsl, ShaderStages.Fragment), + (PickVertexGlsl, ShaderStages.Vertex), + (PickVolumeVertexGlsl, ShaderStages.Vertex), + (PickFragmentGlsl, ShaderStages.Fragment), + (BillboardVertexGlsl, ShaderStages.Vertex), + (BillboardFragmentGlsl, ShaderStages.Fragment), + (OutlineVertexGlsl, ShaderStages.Vertex), + (OutlineFragmentGlsl, ShaderStages.Fragment), + (VolumeVertexGlsl, ShaderStages.Vertex), + (VolumeFragmentGlsl, ShaderStages.Fragment), + ]; + + /// Compiles every shader into the shared SPIR-V cache. Nothing here touches the graphics + /// device, so it can run on any thread, at any time - the point being to run it at startup, while + /// the user is still choosing a level, instead of paying ~2.7s of glslang in the middle of a load. + /// Safe to call more than once and safe to race with a real load: Module takes the same lock and + /// simply finds the entry already there. + public static void WarmUpShaderCache() + { + foreach (var (source, stage) in AllShaders) + { + lock (SpirvCache) + { + if (SpirvCache.ContainsKey((source, stage))) continue; + } + // Compiled OUTSIDE the lock: glslang is the slow part, and holding the lock across it would + // serialise a concurrent renderer build behind the whole warm-up instead of just the miss. + byte[] spirv = SpirvCompilation.CompileGlslToSpirv(source, "vk", stage, new GlslCompileOptions()).SpirvBytes; + lock (SpirvCache) SpirvCache.TryAdd((source, stage), spirv); + } + } + + // Driver-side pipeline cache, shared for the same reason: without it the driver recompiles every + // pipeline's SPIR-V to machine code on each rebuild. Tied to the device it was made on so a device + // change rebuilds it; never destroyed, since it outlives every renderer that uses it. + private static VkPipelineCache _sharedPipelineCache; + private static nint _sharedPipelineCacheDevice; + + private VkPipelineCache GetPipelineCache() + { + nint device = _ctx.BackendInfo.Device; + if (_sharedPipelineCacheDevice == device && _sharedPipelineCache.Handle != 0) return _sharedPipelineCache; + var info = new VkPipelineCacheCreateInfo(); + VkPipelineCache cache; + if (_api.vkCreatePipelineCache(&info, &cache) != VkResult.Success) return default; + _sharedPipelineCache = cache; + _sharedPipelineCacheDevice = device; + return cache; + } + + private VkShaderModule Module(string glsl, ShaderStages stage) + { + byte[] spirv; + lock (SpirvCache) + { + if (!SpirvCache.TryGetValue((glsl, stage), out spirv!)) + { + spirv = SpirvCompilation.CompileGlslToSpirv(glsl, "vk", stage, new GlslCompileOptions()).SpirvBytes; + SpirvCache[(glsl, stage)] = spirv; + } + } + fixed (byte* p = spirv) + { + var info = new VkShaderModuleCreateInfo { codeSize = (nuint)spirv.Length, pCode = (uint*)p }; + VkShaderModule m; Check(_api.vkCreateShaderModule(&info, &m), "vkCreateShaderModule"); + return m; + } + } + + private void CreatePipelines() + { + _vs = Module(VertexGlsl, ShaderStages.Vertex); + _fsOpaque = Module(FragmentOpaqueGlsl, ShaderStages.Fragment); + _fsAccum = Module(FragmentAccumGlsl, ShaderStages.Fragment); + _fsAdditive = Module(FragmentAdditiveGlsl, ShaderStages.Fragment); + _resolveVs = Module(ResolveVertexGlsl, ShaderStages.Vertex); + _resolveFs = Module(ResolveFragmentGlsl, ShaderStages.Fragment); + + // Lit pipeline layout: set0 (scene) + set1 (material) + 32-byte fragment push constant. + VkDescriptorSetLayout* litSets = stackalloc VkDescriptorSetLayout[2] { _descLayout, _matSetLayout }; + var pushRange = new VkPushConstantRange { stageFlags = VkShaderStageFlags.Fragment, offset = 0, size = 32 }; + var litLayoutInfo = new VkPipelineLayoutCreateInfo { setLayoutCount = 2, pSetLayouts = litSets, pushConstantRangeCount = 1, pPushConstantRanges = &pushRange }; + VkPipelineLayout litLayout; Check(_api.vkCreatePipelineLayout(&litLayoutInfo, &litLayout), "vkCreatePipelineLayout(lit)"); _layout = litLayout; + + VkDescriptorSetLayout rl = _resolveSetLayout; + var resolveLayoutInfo = new VkPipelineLayoutCreateInfo { setLayoutCount = 1, pSetLayouts = &rl }; + VkPipelineLayout resolveLayout; Check(_api.vkCreatePipelineLayout(&resolveLayoutInfo, &resolveLayout), "vkCreatePipelineLayout(resolve)"); _resolveLayout = resolveLayout; + + VkPipelineCache pipelineCache = GetPipelineCache(); + byte* entry = stackalloc byte[] { (byte)'m', (byte)'a', (byte)'i', (byte)'n', 0 }; + var mask = VkColorComponentFlags.R | VkColorComponentFlags.G | VkColorComponentFlags.B | VkColorComponentFlags.A; + VkDynamicState* dyn = stackalloc VkDynamicState[2] { VkDynamicState.Viewport, VkDynamicState.Scissor }; + var dynState = new VkPipelineDynamicStateCreateInfo { dynamicStateCount = 2, pDynamicStates = dyn }; + var viewportState = new VkPipelineViewportStateCreateInfo { viewportCount = 1, scissorCount = 1 }; + var multisample = new VkPipelineMultisampleStateCreateInfo { rasterizationSamples = VkSampleCountFlags.Count1 }; + var inputAssembly = new VkPipelineInputAssemblyStateCreateInfo { topology = VkPrimitiveTopology.TriangleList }; + + // --- Lit vertex input (opaque + accumulate). + var vbinding = new VkVertexInputBindingDescription { binding = 0, stride = VertexStride, inputRate = VkVertexInputRate.Vertex }; + VkVertexInputAttributeDescription* attrs = stackalloc VkVertexInputAttributeDescription[6]; + attrs[0] = new VkVertexInputAttributeDescription { location = 0, binding = 0, format = VkFormat.R32G32B32Sfloat, offset = 0 }; + attrs[1] = new VkVertexInputAttributeDescription { location = 1, binding = 0, format = VkFormat.R32G32Sfloat, offset = 12 }; + attrs[2] = new VkVertexInputAttributeDescription { location = 2, binding = 0, format = VkFormat.R32G32B32Sfloat, offset = 20 }; + attrs[3] = new VkVertexInputAttributeDescription { location = 3, binding = 0, format = VkFormat.R32G32B32A32Sfloat, offset = 32 }; + attrs[4] = new VkVertexInputAttributeDescription { location = 4, binding = 0, format = VkFormat.R32G32Sfloat, offset = 48 }; + attrs[5] = new VkVertexInputAttributeDescription { location = 5, binding = 0, format = VkFormat.R32G32B32A32Sfloat, offset = 56 }; + var litVertexInput = new VkPipelineVertexInputStateCreateInfo { vertexBindingDescriptionCount = 1, pVertexBindingDescriptions = &vbinding, vertexAttributeDescriptionCount = 6, pVertexAttributeDescriptions = attrs }; + var rasterCullNone = new VkPipelineRasterizationStateCreateInfo { polygonMode = VkPolygonMode.Fill, cullMode = VkCullModeFlags.None, frontFace = VkFrontFace.CounterClockwise, lineWidth = 1f }; + + // Polygon offset for every NON-opaque draw. This is the decal/overlay offset the game applies, + // and it is engine-global state over the whole non-opaque pass rather than a per-material value: + // the constants are the ones read straight out of a RenderDoc capture of the real game (see + // AssetManager.OverlayDepthBias). Without it, Overlay-mode surfaces - decals, posters, grime - + // z-fight with the wall they are painted on. Applied to the WBOIT accumulate, the additive and + // the Soft-Edge depth-prepass pipelines; Opaque and Cutout draw unbiased. + var rasterBiased = new VkPipelineRasterizationStateCreateInfo { polygonMode = VkPolygonMode.Fill, cullMode = VkCullModeFlags.None, frontFace = VkFrontFace.CounterClockwise, lineWidth = 1f, depthBiasEnable = true, depthBiasConstantFactor = NonOpaqueDepthBias, depthBiasSlopeFactor = NonOpaqueSlopeScaledDepthBias, depthBiasClamp = 0f }; + + // Opaque: depth write, no blend, into _rpOpaque. + { + VkPipelineShaderStageCreateInfo* stages = stackalloc VkPipelineShaderStageCreateInfo[2]; + stages[0] = new VkPipelineShaderStageCreateInfo { stage = VkShaderStageFlags.Vertex, module = _vs, pName = entry }; + stages[1] = new VkPipelineShaderStageCreateInfo { stage = VkShaderStageFlags.Fragment, module = _fsOpaque, pName = entry }; + var depth = new VkPipelineDepthStencilStateCreateInfo { depthTestEnable = true, depthWriteEnable = true, depthCompareOp = VkCompareOp.LessOrEqual }; + var blendAttach = new VkPipelineColorBlendAttachmentState { blendEnable = false, colorWriteMask = mask }; + var blend = new VkPipelineColorBlendStateCreateInfo { attachmentCount = 1, pAttachments = &blendAttach }; + var info = new VkGraphicsPipelineCreateInfo { stageCount = 2, pStages = stages, pVertexInputState = &litVertexInput, pInputAssemblyState = &inputAssembly, pViewportState = &viewportState, pRasterizationState = &rasterCullNone, pMultisampleState = &multisample, pDepthStencilState = &depth, pColorBlendState = &blend, pDynamicState = &dynState, layout = _layout, renderPass = _rpOpaque, subpass = 0 }; + VkPipeline p; Check(_api.vkCreateGraphicsPipelines(pipelineCache, 1, &info, &p), "vkCreateGraphicsPipelines(opaque)"); _pipelineOpaque = p; + } + + // Accumulate: depth test only, two attachments - accum additive, reveal multiplicative - into _rpAccum. + { + VkPipelineShaderStageCreateInfo* stages = stackalloc VkPipelineShaderStageCreateInfo[2]; + stages[0] = new VkPipelineShaderStageCreateInfo { stage = VkShaderStageFlags.Vertex, module = _vs, pName = entry }; + stages[1] = new VkPipelineShaderStageCreateInfo { stage = VkShaderStageFlags.Fragment, module = _fsAccum, pName = entry }; + var depth = new VkPipelineDepthStencilStateCreateInfo { depthTestEnable = true, depthWriteEnable = false, depthCompareOp = VkCompareOp.LessOrEqual }; + VkPipelineColorBlendAttachmentState* atts = stackalloc VkPipelineColorBlendAttachmentState[2]; + atts[0] = new VkPipelineColorBlendAttachmentState { blendEnable = true, srcColorBlendFactor = VkBlendFactor.One, dstColorBlendFactor = VkBlendFactor.One, colorBlendOp = VkBlendOp.Add, srcAlphaBlendFactor = VkBlendFactor.One, dstAlphaBlendFactor = VkBlendFactor.One, alphaBlendOp = VkBlendOp.Add, colorWriteMask = mask }; + atts[1] = new VkPipelineColorBlendAttachmentState { blendEnable = true, srcColorBlendFactor = VkBlendFactor.Zero, dstColorBlendFactor = VkBlendFactor.OneMinusSrcColor, colorBlendOp = VkBlendOp.Add, srcAlphaBlendFactor = VkBlendFactor.Zero, dstAlphaBlendFactor = VkBlendFactor.OneMinusSrcColor, alphaBlendOp = VkBlendOp.Add, colorWriteMask = VkColorComponentFlags.R }; + var blend = new VkPipelineColorBlendStateCreateInfo { attachmentCount = 2, pAttachments = atts }; + var info = new VkGraphicsPipelineCreateInfo { stageCount = 2, pStages = stages, pVertexInputState = &litVertexInput, pInputAssemblyState = &inputAssembly, pViewportState = &viewportState, pRasterizationState = &rasterBiased, pMultisampleState = &multisample, pDepthStencilState = &depth, pColorBlendState = &blend, pDynamicState = &dynState, layout = _layout, renderPass = _rpAccum, subpass = 0 }; + VkPipeline p; Check(_api.vkCreateGraphicsPipelines(pipelineCache, 1, &info, &p), "vkCreateGraphicsPipelines(accum)"); _pipelineAccum = p; + } + + // Resolve: fullscreen triangle, no vertex input, blend over the opaque colour, into _rpResolve. + { + VkPipelineShaderStageCreateInfo* stages = stackalloc VkPipelineShaderStageCreateInfo[2]; + stages[0] = new VkPipelineShaderStageCreateInfo { stage = VkShaderStageFlags.Vertex, module = _resolveVs, pName = entry }; + stages[1] = new VkPipelineShaderStageCreateInfo { stage = VkShaderStageFlags.Fragment, module = _resolveFs, pName = entry }; + var emptyVertexInput = new VkPipelineVertexInputStateCreateInfo(); + var rasterCullNoneR = new VkPipelineRasterizationStateCreateInfo { polygonMode = VkPolygonMode.Fill, cullMode = VkCullModeFlags.None, frontFace = VkFrontFace.CounterClockwise, lineWidth = 1f }; + var depth = new VkPipelineDepthStencilStateCreateInfo { depthTestEnable = false, depthWriteEnable = false, depthCompareOp = VkCompareOp.Always }; + // final = avg*(1-reveal) + dst*reveal; keep dst alpha (=1). src.a = reveal. + var blendAttach = new VkPipelineColorBlendAttachmentState { blendEnable = true, srcColorBlendFactor = VkBlendFactor.OneMinusSrcAlpha, dstColorBlendFactor = VkBlendFactor.SrcAlpha, colorBlendOp = VkBlendOp.Add, srcAlphaBlendFactor = VkBlendFactor.Zero, dstAlphaBlendFactor = VkBlendFactor.One, alphaBlendOp = VkBlendOp.Add, colorWriteMask = mask }; + var blend = new VkPipelineColorBlendStateCreateInfo { attachmentCount = 1, pAttachments = &blendAttach }; + var info = new VkGraphicsPipelineCreateInfo { stageCount = 2, pStages = stages, pVertexInputState = &emptyVertexInput, pInputAssemblyState = &inputAssembly, pViewportState = &viewportState, pRasterizationState = &rasterCullNoneR, pMultisampleState = &multisample, pDepthStencilState = &depth, pColorBlendState = &blend, pDynamicState = &dynState, layout = _resolveLayout, renderPass = _rpResolve, subpass = 0 }; + VkPipeline p; Check(_api.vkCreateGraphicsPipelines(pipelineCache, 1, &info, &p), "vkCreateGraphicsPipelines(resolve)"); _pipelineResolve = p; + } + + // Soft-Edge depth prepass (mode 5, pass 1): alpha-tested depth-only draw into the opaque pass - + // colour writes OFF, depth write ON. This is what makes soft-edge geometry occlude correctly + // before its blended colour pass; a plain alpha blend (what we did before) looks wrong. + { + VkPipelineShaderStageCreateInfo* stages = stackalloc VkPipelineShaderStageCreateInfo[2]; + stages[0] = new VkPipelineShaderStageCreateInfo { stage = VkShaderStageFlags.Vertex, module = _vs, pName = entry }; + stages[1] = new VkPipelineShaderStageCreateInfo { stage = VkShaderStageFlags.Fragment, module = _fsOpaque, pName = entry }; + var depth = new VkPipelineDepthStencilStateCreateInfo { depthTestEnable = true, depthWriteEnable = true, depthCompareOp = VkCompareOp.LessOrEqual }; + var noColor = new VkPipelineColorBlendAttachmentState { blendEnable = false, colorWriteMask = 0 }; + var blend = new VkPipelineColorBlendStateCreateInfo { attachmentCount = 1, pAttachments = &noColor }; + var info = new VkGraphicsPipelineCreateInfo { stageCount = 2, pStages = stages, pVertexInputState = &litVertexInput, pInputAssemblyState = &inputAssembly, pViewportState = &viewportState, pRasterizationState = &rasterBiased, pMultisampleState = &multisample, pDepthStencilState = &depth, pColorBlendState = &blend, pDynamicState = &dynState, layout = _layout, renderPass = _rpOpaque, subpass = 0 }; + VkPipeline p; Check(_api.vkCreateGraphicsPipelines(pipelineCache, 1, &info, &p), "vkCreateGraphicsPipelines(softEdgeDepth)"); _pipelineSoftEdgeDepth = p; + } + + // Additive (mode 2): SrcAlpha/One - the surface ADDS light to what's behind it, so it must not + // go through the over-blend WBOIT path. Additive blending is commutative, so it needs no sorting; + // drawn depth-tested (no write) straight into the opaque colour after everything else. + { + VkPipelineShaderStageCreateInfo* stages = stackalloc VkPipelineShaderStageCreateInfo[2]; + stages[0] = new VkPipelineShaderStageCreateInfo { stage = VkShaderStageFlags.Vertex, module = _vs, pName = entry }; + stages[1] = new VkPipelineShaderStageCreateInfo { stage = VkShaderStageFlags.Fragment, module = _fsAdditive, pName = entry }; + var depth = new VkPipelineDepthStencilStateCreateInfo { depthTestEnable = true, depthWriteEnable = false, depthCompareOp = VkCompareOp.LessOrEqual }; + var add = new VkPipelineColorBlendAttachmentState + { + blendEnable = true, + srcColorBlendFactor = VkBlendFactor.SrcAlpha, dstColorBlendFactor = VkBlendFactor.One, colorBlendOp = VkBlendOp.Add, + srcAlphaBlendFactor = VkBlendFactor.Zero, dstAlphaBlendFactor = VkBlendFactor.One, alphaBlendOp = VkBlendOp.Add, + colorWriteMask = mask, + }; + var blend = new VkPipelineColorBlendStateCreateInfo { attachmentCount = 1, pAttachments = &add }; + var info = new VkGraphicsPipelineCreateInfo { stageCount = 2, pStages = stages, pVertexInputState = &litVertexInput, pInputAssemblyState = &inputAssembly, pViewportState = &viewportState, pRasterizationState = &rasterBiased, pMultisampleState = &multisample, pDepthStencilState = &depth, pColorBlendState = &blend, pDynamicState = &dynState, layout = _layout, renderPass = _rpOpaque, subpass = 0 }; + VkPipeline p; Check(_api.vkCreateGraphicsPipelines(pipelineCache, 1, &info, &p), "vkCreateGraphicsPipelines(additive)"); _pipelineAdditive = p; + } + + // Volumes: line-list wireframe cube, depth-tested + writing (occluded by opaque scene), drawn in + // the opaque pass. Own layout = set0 (for uMvp) + a Vertex|Fragment push constant (mat4 + colour). + _volumeVs = Module(VolumeVertexGlsl, ShaderStages.Vertex); + _volumeFs = Module(VolumeFragmentGlsl, ShaderStages.Fragment); + VkDescriptorSetLayout vl = _descLayout; + var volPush = new VkPushConstantRange { stageFlags = VkShaderStageFlags.Vertex | VkShaderStageFlags.Fragment, offset = 0, size = 80 }; + var volLayoutInfo = new VkPipelineLayoutCreateInfo { setLayoutCount = 1, pSetLayouts = &vl, pushConstantRangeCount = 1, pPushConstantRanges = &volPush }; + VkPipelineLayout volLayout; Check(_api.vkCreatePipelineLayout(&volLayoutInfo, &volLayout), "vkCreatePipelineLayout(volume)"); _volumeLayout = volLayout; + { + VkPipelineShaderStageCreateInfo* stages = stackalloc VkPipelineShaderStageCreateInfo[2]; + stages[0] = new VkPipelineShaderStageCreateInfo { stage = VkShaderStageFlags.Vertex, module = _volumeVs, pName = entry }; + stages[1] = new VkPipelineShaderStageCreateInfo { stage = VkShaderStageFlags.Fragment, module = _volumeFs, pName = entry }; + var vbind = new VkVertexInputBindingDescription { binding = 0, stride = 3 * sizeof(float), inputRate = VkVertexInputRate.Vertex }; + var vattr = new VkVertexInputAttributeDescription { location = 0, binding = 0, format = VkFormat.R32G32B32Sfloat, offset = 0 }; + var vin = new VkPipelineVertexInputStateCreateInfo { vertexBindingDescriptionCount = 1, pVertexBindingDescriptions = &vbind, vertexAttributeDescriptionCount = 1, pVertexAttributeDescriptions = &vattr }; + var ia = new VkPipelineInputAssemblyStateCreateInfo { topology = VkPrimitiveTopology.TriangleList }; + var depth = new VkPipelineDepthStencilStateCreateInfo { depthTestEnable = true, depthWriteEnable = true, depthCompareOp = VkCompareOp.LessOrEqual }; + var blendAttach = new VkPipelineColorBlendAttachmentState { blendEnable = false, colorWriteMask = mask }; + var blend = new VkPipelineColorBlendStateCreateInfo { attachmentCount = 1, pAttachments = &blendAttach }; + var info = new VkGraphicsPipelineCreateInfo { stageCount = 2, pStages = stages, pVertexInputState = &vin, pInputAssemblyState = &ia, pViewportState = &viewportState, pRasterizationState = &rasterCullNone, pMultisampleState = &multisample, pDepthStencilState = &depth, pColorBlendState = &blend, pDynamicState = &dynState, layout = _volumeLayout, renderPass = _rpOpaque, subpass = 0 }; + VkPipeline p; Check(_api.vkCreateGraphicsPipelines(pipelineCache, 1, &info, &p), "vkCreateGraphicsPipelines(volume)"); _volumePipeline = p; + } + + // Foliage billboards: the lit vertex layout and the lit pipeline layout (so set0/set1 and the + // push constant are shared), but the billboard vertex shader and a plain alpha-tested textured + // fragment shader. Drawn in the opaque pass, depth-writing - see the alpha-reference note above. + _billboardVs = Module(BillboardVertexGlsl, ShaderStages.Vertex); + _billboardFs = Module(BillboardFragmentGlsl, ShaderStages.Fragment); + { + VkPipelineShaderStageCreateInfo* stages = stackalloc VkPipelineShaderStageCreateInfo[2]; + stages[0] = new VkPipelineShaderStageCreateInfo { stage = VkShaderStageFlags.Vertex, module = _billboardVs, pName = entry }; + stages[1] = new VkPipelineShaderStageCreateInfo { stage = VkShaderStageFlags.Fragment, module = _billboardFs, pName = entry }; + var depth = new VkPipelineDepthStencilStateCreateInfo { depthTestEnable = true, depthWriteEnable = true, depthCompareOp = VkCompareOp.LessOrEqual }; + var blendAttach = new VkPipelineColorBlendAttachmentState { blendEnable = false, colorWriteMask = mask }; + var blend = new VkPipelineColorBlendStateCreateInfo { attachmentCount = 1, pAttachments = &blendAttach }; + var info = new VkGraphicsPipelineCreateInfo { stageCount = 2, pStages = stages, pVertexInputState = &litVertexInput, pInputAssemblyState = &inputAssembly, pViewportState = &viewportState, pRasterizationState = &rasterCullNone, pMultisampleState = &multisample, pDepthStencilState = &depth, pColorBlendState = &blend, pDynamicState = &dynState, layout = _layout, renderPass = _rpOpaque, subpass = 0 }; + VkPipeline p; Check(_api.vkCreateGraphicsPipelines(pipelineCache, 1, &info, &p), "vkCreateGraphicsPipelines(billboard)"); _pipelineBillboard = p; + } + + // Shared by the debug-line and selection-outline pipelines: set0 (camera UBO + transform SSBO) + // plus a Vertex|Fragment push constant. Created BEFORE any pipeline that references it - a + // VkGraphicsPipelineCreateInfo with a null layout handle is undefined behaviour, and in + // practice segfaults inside the driver at renderer construction. + VkDescriptorSetLayout ol = _descLayout; + var outlinePush = new VkPushConstantRange { stageFlags = VkShaderStageFlags.Vertex | VkShaderStageFlags.Fragment, offset = 0, size = 32 }; + var outlineLayoutInfo = new VkPipelineLayoutCreateInfo { setLayoutCount = 1, pSetLayouts = &ol, pushConstantRangeCount = 1, pPushConstantRanges = &outlinePush }; + VkPipelineLayout outLayout; Check(_api.vkCreatePipelineLayout(&outlineLayoutInfo, &outLayout), "vkCreatePipelineLayout(outline)"); _outlineLayout = outLayout; + + // Debug lines: drawn in the resolve pass (so they sit on the finished image) with the depth + // test off and straight alpha blending. + _debugLineVs = Module(DebugLineVertexGlsl, ShaderStages.Vertex); + _debugLineFs = Module(DebugLineFragmentGlsl, ShaderStages.Fragment); + { + VkPipelineShaderStageCreateInfo* stages = stackalloc VkPipelineShaderStageCreateInfo[2]; + stages[0] = new VkPipelineShaderStageCreateInfo { stage = VkShaderStageFlags.Vertex, module = _debugLineVs, pName = entry }; + stages[1] = new VkPipelineShaderStageCreateInfo { stage = VkShaderStageFlags.Fragment, module = _debugLineFs, pName = entry }; + var lbind = new VkVertexInputBindingDescription { binding = 0, stride = DebugLineFloats * sizeof(float), inputRate = VkVertexInputRate.Vertex }; + VkVertexInputAttributeDescription* lattrs = stackalloc VkVertexInputAttributeDescription[2]; + lattrs[0] = new VkVertexInputAttributeDescription { location = 0, binding = 0, format = VkFormat.R32G32B32Sfloat, offset = 0 }; + lattrs[1] = new VkVertexInputAttributeDescription { location = 1, binding = 0, format = VkFormat.R32G32B32A32Sfloat, offset = 12 }; + var lin = new VkPipelineVertexInputStateCreateInfo { vertexBindingDescriptionCount = 1, pVertexBindingDescriptions = &lbind, vertexAttributeDescriptionCount = 2, pVertexAttributeDescriptions = lattrs }; + var lia = new VkPipelineInputAssemblyStateCreateInfo { topology = VkPrimitiveTopology.LineList }; + var ldepth = new VkPipelineDepthStencilStateCreateInfo { depthTestEnable = false, depthWriteEnable = false, depthCompareOp = VkCompareOp.Always }; + var lblendAttach = new VkPipelineColorBlendAttachmentState { blendEnable = true, srcColorBlendFactor = VkBlendFactor.SrcAlpha, dstColorBlendFactor = VkBlendFactor.OneMinusSrcAlpha, colorBlendOp = VkBlendOp.Add, srcAlphaBlendFactor = VkBlendFactor.One, dstAlphaBlendFactor = VkBlendFactor.OneMinusSrcAlpha, alphaBlendOp = VkBlendOp.Add, colorWriteMask = mask }; + var lblend = new VkPipelineColorBlendStateCreateInfo { attachmentCount = 1, pAttachments = &lblendAttach }; + var linfo = new VkGraphicsPipelineCreateInfo { stageCount = 2, pStages = stages, pVertexInputState = &lin, pInputAssemblyState = &lia, pViewportState = &viewportState, pRasterizationState = &rasterCullNone, pMultisampleState = &multisample, pDepthStencilState = &ldepth, pColorBlendState = &lblend, pDynamicState = &dynState, layout = _outlineLayout, renderPass = _rpResolve, subpass = 0 }; + VkPipeline lp; Check(_api.vkCreateGraphicsPipelines(pipelineCache, 1, &linfo, &lp), "vkCreateGraphicsPipelines(debugLines)"); _pipelineDebugLines = lp; + } + + // Selection outline: two pipelines over the same shader pair, drawn in the RESOLVE pass so the + // rim sits on top of the fully composited image (opaque + resolved translucent) rather than + // underneath the glass. Own layout = set0 (camera UBO + transform SSBO) + a Vertex|Fragment + // push constant (colour + inflate amount). + _outlineVs = Module(OutlineVertexGlsl, ShaderStages.Vertex); + _outlineFs = Module(OutlineFragmentGlsl, ShaderStages.Fragment); + { + VkPipelineShaderStageCreateInfo* stages = stackalloc VkPipelineShaderStageCreateInfo[2]; + stages[0] = new VkPipelineShaderStageCreateInfo { stage = VkShaderStageFlags.Vertex, module = _outlineVs, pName = entry }; + stages[1] = new VkPipelineShaderStageCreateInfo { stage = VkShaderStageFlags.Fragment, module = _outlineFs, pName = entry }; + + // Pass 1 (mask): depth-tested against the scene, no depth write, NO colour write, stamp 1. + var stamp = new VkStencilOpState { failOp = VkStencilOp.Keep, passOp = VkStencilOp.Replace, depthFailOp = VkStencilOp.Keep, compareOp = VkCompareOp.Always, compareMask = 0xFF, writeMask = 0xFF, reference = 1 }; + var maskDepth = new VkPipelineDepthStencilStateCreateInfo { depthTestEnable = true, depthWriteEnable = false, depthCompareOp = VkCompareOp.LessOrEqual, stencilTestEnable = true, front = stamp, back = stamp }; + var noColor = new VkPipelineColorBlendAttachmentState { blendEnable = false, colorWriteMask = 0 }; + var maskBlend = new VkPipelineColorBlendStateCreateInfo { attachmentCount = 1, pAttachments = &noColor }; + var maskInfo = new VkGraphicsPipelineCreateInfo { stageCount = 2, pStages = stages, pVertexInputState = &litVertexInput, pInputAssemblyState = &inputAssembly, pViewportState = &viewportState, pRasterizationState = &rasterCullNone, pMultisampleState = &multisample, pDepthStencilState = &maskDepth, pColorBlendState = &maskBlend, pDynamicState = &dynState, layout = _outlineLayout, renderPass = _rpResolve, subpass = 0 }; + VkPipeline pm; Check(_api.vkCreateGraphicsPipelines(pipelineCache, 1, &maskInfo, &pm), "vkCreateGraphicsPipelines(outlineMask)"); _pipelineOutlineMask = pm; + + // Pass 2 (rim): same depth test, stencil NotEqual 1 so only what pass 1 did NOT cover draws. + var rim = new VkStencilOpState { failOp = VkStencilOp.Keep, passOp = VkStencilOp.Keep, depthFailOp = VkStencilOp.Keep, compareOp = VkCompareOp.NotEqual, compareMask = 0xFF, writeMask = 0, reference = 1 }; + var rimDepth = new VkPipelineDepthStencilStateCreateInfo { depthTestEnable = true, depthWriteEnable = false, depthCompareOp = VkCompareOp.LessOrEqual, stencilTestEnable = true, front = rim, back = rim }; + var rimAttach = new VkPipelineColorBlendAttachmentState { blendEnable = false, colorWriteMask = mask }; + var rimBlend = new VkPipelineColorBlendStateCreateInfo { attachmentCount = 1, pAttachments = &rimAttach }; + var rimInfo = new VkGraphicsPipelineCreateInfo { stageCount = 2, pStages = stages, pVertexInputState = &litVertexInput, pInputAssemblyState = &inputAssembly, pViewportState = &viewportState, pRasterizationState = &rasterCullNone, pMultisampleState = &multisample, pDepthStencilState = &rimDepth, pColorBlendState = &rimBlend, pDynamicState = &dynState, layout = _outlineLayout, renderPass = _rpResolve, subpass = 0 }; + VkPipeline pr; Check(_api.vkCreateGraphicsPipelines(pipelineCache, 1, &rimInfo, &pr), "vkCreateGraphicsPipelines(outlineRim)"); _pipelineOutlineRim = pr; + } + + // Picking: scene geometry (transform SSBO, id per draw) and volume edges (world matrix per + // draw) into the same tiny id target. One layout for both - a mat4 + a uvec4 push constant, + // 80 bytes, well inside the 128-byte guaranteed minimum. + _pickVs = Module(PickVertexGlsl, ShaderStages.Vertex); + _pickVolumeVs = Module(PickVolumeVertexGlsl, ShaderStages.Vertex); + _pickBillboardVs = Module(PickBillboardVertexGlsl, ShaderStages.Vertex); + _pickFs = Module(PickFragmentGlsl, ShaderStages.Fragment); + VkDescriptorSetLayout pl = _descLayout; + var pickPush = new VkPushConstantRange { stageFlags = VkShaderStageFlags.Vertex | VkShaderStageFlags.Fragment, offset = 0, size = 80 }; + var pickLayoutInfo = new VkPipelineLayoutCreateInfo { setLayoutCount = 1, pSetLayouts = &pl, pushConstantRangeCount = 1, pPushConstantRanges = &pickPush }; + VkPipelineLayout pkLayout; Check(_api.vkCreatePipelineLayout(&pickLayoutInfo, &pkLayout), "vkCreatePipelineLayout(pick)"); _pickLayout = pkLayout; + { + var depth = new VkPipelineDepthStencilStateCreateInfo { depthTestEnable = true, depthWriteEnable = true, depthCompareOp = VkCompareOp.LessOrEqual }; + var attach = new VkPipelineColorBlendAttachmentState { blendEnable = false, colorWriteMask = mask }; + var blend = new VkPipelineColorBlendStateCreateInfo { attachmentCount = 1, pAttachments = &attach }; + + VkPipelineShaderStageCreateInfo* stages = stackalloc VkPipelineShaderStageCreateInfo[2]; + stages[0] = new VkPipelineShaderStageCreateInfo { stage = VkShaderStageFlags.Vertex, module = _pickVs, pName = entry }; + stages[1] = new VkPipelineShaderStageCreateInfo { stage = VkShaderStageFlags.Fragment, module = _pickFs, pName = entry }; + var info = new VkGraphicsPipelineCreateInfo { stageCount = 2, pStages = stages, pVertexInputState = &litVertexInput, pInputAssemblyState = &inputAssembly, pViewportState = &viewportState, pRasterizationState = &rasterCullNone, pMultisampleState = &multisample, pDepthStencilState = &depth, pColorBlendState = &blend, pDynamicState = &dynState, layout = _pickLayout, renderPass = _rpPick, subpass = 0 }; + VkPipeline p; Check(_api.vkCreateGraphicsPipelines(pipelineCache, 1, &info, &p), "vkCreateGraphicsPipelines(pick)"); _pipelinePick = p; + + VkPipelineShaderStageCreateInfo* vstages = stackalloc VkPipelineShaderStageCreateInfo[2]; + vstages[0] = new VkPipelineShaderStageCreateInfo { stage = VkShaderStageFlags.Vertex, module = _pickVolumeVs, pName = entry }; + vstages[1] = new VkPipelineShaderStageCreateInfo { stage = VkShaderStageFlags.Fragment, module = _pickFs, pName = entry }; + var vbind = new VkVertexInputBindingDescription { binding = 0, stride = 3 * sizeof(float), inputRate = VkVertexInputRate.Vertex }; + var vattr = new VkVertexInputAttributeDescription { location = 0, binding = 0, format = VkFormat.R32G32B32Sfloat, offset = 0 }; + var vin = new VkPipelineVertexInputStateCreateInfo { vertexBindingDescriptionCount = 1, pVertexBindingDescriptions = &vbind, vertexAttributeDescriptionCount = 1, pVertexAttributeDescriptions = &vattr }; + var vinfo = new VkGraphicsPipelineCreateInfo { stageCount = 2, pStages = vstages, pVertexInputState = &vin, pInputAssemblyState = &inputAssembly, pViewportState = &viewportState, pRasterizationState = &rasterCullNone, pMultisampleState = &multisample, pDepthStencilState = &depth, pColorBlendState = &blend, pDynamicState = &dynState, layout = _pickLayout, renderPass = _rpPick, subpass = 0 }; + VkPipeline vp; Check(_api.vkCreateGraphicsPipelines(pipelineCache, 1, &vinfo, &vp), "vkCreateGraphicsPipelines(pickVolume)"); _pipelinePickVolume = vp; + + // Foliage billboards: same vertex layout and pipeline state as the generic pick pipeline + // above, just PickBillboardVertexGlsl in place of PickVertexGlsl so the card's corner + // offset gets applied before projecting - see that shader's comment. + VkPipelineShaderStageCreateInfo* bstages = stackalloc VkPipelineShaderStageCreateInfo[2]; + bstages[0] = new VkPipelineShaderStageCreateInfo { stage = VkShaderStageFlags.Vertex, module = _pickBillboardVs, pName = entry }; + bstages[1] = new VkPipelineShaderStageCreateInfo { stage = VkShaderStageFlags.Fragment, module = _pickFs, pName = entry }; + var binfo = new VkGraphicsPipelineCreateInfo { stageCount = 2, pStages = bstages, pVertexInputState = &litVertexInput, pInputAssemblyState = &inputAssembly, pViewportState = &viewportState, pRasterizationState = &rasterCullNone, pMultisampleState = &multisample, pDepthStencilState = &depth, pColorBlendState = &blend, pDynamicState = &dynState, layout = _pickLayout, renderPass = _rpPick, subpass = 0 }; + VkPipeline bp; Check(_api.vkCreateGraphicsPipelines(pipelineCache, 1, &binfo, &bp), "vkCreateGraphicsPipelines(pickBillboard)"); _pipelinePickBillboard = bp; + } + } + + // The pick target never changes size (the window is a fixed pixel count), so it is built once. + private void CreatePickResources() + { + (_pickImage, _pickMemory, _pickView) = CreateAttachmentImageSized(ColorFormat, VkImageUsageFlags.ColorAttachment | VkImageUsageFlags.TransferSrc, VkImageAspectFlags.Color, PickTargetSize); + (_pickDepthImage, _pickDepthMemory, _pickDepthView) = CreateAttachmentImageSized(DepthFormat, VkImageUsageFlags.DepthStencilAttachment, VkImageAspectFlags.Depth | VkImageAspectFlags.Stencil, PickTargetSize); + + VkImageView* views = stackalloc VkImageView[2] { _pickView, _pickDepthView }; + var fb = new VkFramebufferCreateInfo { renderPass = _rpPick, attachmentCount = 2, pAttachments = views, width = PickTargetSize, height = PickTargetSize, layers = 1 }; + VkFramebuffer f; Check(_api.vkCreateFramebuffer(&fb, &f), "vkCreateFramebuffer(pick)"); _fbPick = f; + + ulong size = PickTargetSize * PickTargetSize * 4; + (_pickReadback, _pickReadbackMemory) = CreateBuffer(size, VkBufferUsageFlags.TransferDst, VkMemoryPropertyFlags.HostVisible | VkMemoryPropertyFlags.HostCoherent); + void* p; Check(_api.vkMapMemory(_pickReadbackMemory, 0, size, 0, &p), "vkMapMemory(pickReadback)"); _pickReadbackMapped = p; + + var cbAlloc = new VkCommandBufferAllocateInfo { commandPool = _pool, level = VkCommandBufferLevel.Primary, commandBufferCount = 1 }; + VkCommandBuffer cb; Check(_api.vkAllocateCommandBuffers(&cbAlloc, &cb), "vkAllocateCommandBuffers(pick)"); _pickCmd = cb; + var fenceInfo = new VkFenceCreateInfo(); + VkFence fence; Check(_api.vkCreateFence(&fenceInfo, &fence), "vkCreateFence(pick)"); _pickFence = fence; + } + + private static Texture CreateFallbackCubemap(GraphicsDevice gd) + { + var tex = gd.ResourceFactory.CreateTexture(TextureDescription.Texture2D( + 1, 1, 1, 6, PixelFormat.R8G8B8A8UNorm, TextureUsage.Sampled | TextureUsage.Cubemap)); + var grey = new byte[] { 128, 128, 128, 128 }; + for (uint face = 0; face < 6; face++) + gd.UpdateTexture(tex, grey, 0, 0, 0, 1, 1, 1, 0, face); + return tex; + } + + private (VkImage, VkDeviceMemory, VkImageView) CreateAttachmentImageSized(VkFormat format, VkImageUsageFlags usage, VkImageAspectFlags aspect, uint size) + { + var info = new VkImageCreateInfo { imageType = VkImageType.Image2D, format = format, extent = new VkExtent3D { width = size, height = size, depth = 1 }, mipLevels = 1, arrayLayers = 1, samples = VkSampleCountFlags.Count1, tiling = VkImageTiling.Optimal, usage = usage, sharingMode = VkSharingMode.Exclusive, initialLayout = VkImageLayout.Undefined }; + VkImage image; Check(_api.vkCreateImage(&info, &image), "vkCreateImage"); + VkMemoryRequirements reqs; _api.vkGetImageMemoryRequirements(image, &reqs); + VkDeviceMemory memory = Allocate(reqs, VkMemoryPropertyFlags.DeviceLocal); + Check(_api.vkBindImageMemory(image, memory, 0), "vkBindImageMemory"); + var viewInfo = new VkImageViewCreateInfo { image = image, viewType = VkImageViewType.Image2D, format = format, components = default, subresourceRange = new VkImageSubresourceRange { aspectMask = aspect, baseMipLevel = 0, levelCount = 1, baseArrayLayer = 0, layerCount = 1 } }; + VkImageView view; Check(_api.vkCreateImageView(&viewInfo, &view), "vkCreateImageView"); + return (image, memory, view); + } + + private (VkImage, VkDeviceMemory, VkImageView) CreateAttachmentImage(VkFormat format, VkImageUsageFlags usage, VkImageAspectFlags aspect) + { + var info = new VkImageCreateInfo { imageType = VkImageType.Image2D, format = format, extent = new VkExtent3D { width = _width, height = _height, depth = 1 }, mipLevels = 1, arrayLayers = 1, samples = VkSampleCountFlags.Count1, tiling = VkImageTiling.Optimal, usage = usage, sharingMode = VkSharingMode.Exclusive, initialLayout = VkImageLayout.Undefined }; + VkImage image; Check(_api.vkCreateImage(&info, &image), "vkCreateImage"); + VkMemoryRequirements reqs; _api.vkGetImageMemoryRequirements(image, &reqs); + VkDeviceMemory memory = Allocate(reqs, VkMemoryPropertyFlags.DeviceLocal); + Check(_api.vkBindImageMemory(image, memory, 0), "vkBindImageMemory"); + var viewInfo = new VkImageViewCreateInfo { image = image, viewType = VkImageViewType.Image2D, format = format, components = default, subresourceRange = new VkImageSubresourceRange { aspectMask = aspect, baseMipLevel = 0, levelCount = 1, baseArrayLayer = 0, layerCount = 1 } }; + VkImageView view; Check(_api.vkCreateImageView(&viewInfo, &view), "vkCreateImageView"); + return (image, memory, view); + } + + private void CreateTargets(GraphicsDevice gd) + { + for (int f = 0; f < Frames; f++) + { + _colorTex[f] = gd.ResourceFactory.CreateTexture(TextureDescription.Texture2D(_width, _height, 1, 1, PixelFormat.R8G8B8A8UNorm, TextureUsage.Sampled | TextureUsage.RenderTarget)); + _colorImage[f] = _ctx.BackendInfo.GetVkImage(_colorTex[f]); + var cvi = new VkImageViewCreateInfo { image = _colorImage[f], viewType = VkImageViewType.Image2D, format = ColorFormat, components = default, subresourceRange = new VkImageSubresourceRange { aspectMask = VkImageAspectFlags.Color, baseMipLevel = 0, levelCount = 1, baseArrayLayer = 0, layerCount = 1 } }; + VkImageView cv; Check(_api.vkCreateImageView(&cvi, &cv), "vkCreateImageView(color)"); _colorView[f] = cv; + } + + (_depthImage, _depthMemory, _depthView) = CreateAttachmentImage(DepthFormat, VkImageUsageFlags.DepthStencilAttachment, VkImageAspectFlags.Depth | VkImageAspectFlags.Stencil); + (_accumImage, _accumMemory, _accumView) = CreateAttachmentImage(AccumFormat, VkImageUsageFlags.ColorAttachment | VkImageUsageFlags.Sampled, VkImageAspectFlags.Color); + (_revealImage, _revealMemory, _revealView) = CreateAttachmentImage(RevealFormat, VkImageUsageFlags.ColorAttachment | VkImageUsageFlags.Sampled, VkImageAspectFlags.Color); + + // Opaque and resolve write colour, so they are per-slot. Accum only touches the shared + // accum/reveal/depth attachments, so one is enough. + VkImageView* oViews = stackalloc VkImageView[2]; + VkImageView* rViews = stackalloc VkImageView[2]; + for (int f = 0; f < Frames; f++) + { + oViews[0] = _colorView[f]; oViews[1] = _depthView; + var fbO = new VkFramebufferCreateInfo { renderPass = _rpOpaque, attachmentCount = 2, pAttachments = oViews, width = _width, height = _height, layers = 1 }; + VkFramebuffer fo; Check(_api.vkCreateFramebuffer(&fbO, &fo), "vkCreateFramebuffer(opaque)"); _fbOpaque[f] = fo; + + rViews[0] = _colorView[f]; rViews[1] = _depthView; + var fbR = new VkFramebufferCreateInfo { renderPass = _rpResolve, attachmentCount = 2, pAttachments = rViews, width = _width, height = _height, layers = 1 }; + VkFramebuffer fr; Check(_api.vkCreateFramebuffer(&fbR, &fr), "vkCreateFramebuffer(resolve)"); _fbResolve[f] = fr; + } + + VkImageView* aViews = stackalloc VkImageView[3] { _accumView, _revealView, _depthView }; + var fbA = new VkFramebufferCreateInfo { renderPass = _rpAccum, attachmentCount = 3, pAttachments = aViews, width = _width, height = _height, layers = 1 }; + VkFramebuffer fa; Check(_api.vkCreateFramebuffer(&fbA, &fa), "vkCreateFramebuffer(accum)"); _fbAccum = fa; + + // (Re)point the resolve descriptor set at the current accum/reveal views. + var accumInfo = new VkDescriptorImageInfo { sampler = _sampler, imageView = _accumView, imageLayout = VkImageLayout.ShaderReadOnlyOptimal }; + var revealInfo = new VkDescriptorImageInfo { sampler = _sampler, imageView = _revealView, imageLayout = VkImageLayout.ShaderReadOnlyOptimal }; + VkWriteDescriptorSet* wr = stackalloc VkWriteDescriptorSet[2]; + wr[0] = new VkWriteDescriptorSet { dstSet = _resolveSet, dstBinding = 0, descriptorCount = 1, descriptorType = VkDescriptorType.CombinedImageSampler, pImageInfo = &accumInfo }; + wr[1] = new VkWriteDescriptorSet { dstSet = _resolveSet, dstBinding = 1, descriptorCount = 1, descriptorType = VkDescriptorType.CombinedImageSampler, pImageInfo = &revealInfo }; + _api.vkUpdateDescriptorSets(2, wr, 0, null); + } + + private void DestroyTargets() + { + for (int f = 0; f < Frames; f++) + { + _api.vkDestroyFramebuffer(_fbResolve[f]); + _api.vkDestroyFramebuffer(_fbOpaque[f]); + _api.vkDestroyImageView(_colorView[f]); + _colorTex[f]?.Dispose(); + _colorTex[f] = null!; + } + _api.vkDestroyFramebuffer(_fbAccum); + _api.vkDestroyImageView(_revealView); _api.vkDestroyImage(_revealImage); _api.vkFreeMemory(_revealMemory); + _api.vkDestroyImageView(_accumView); _api.vkDestroyImage(_accumImage); _api.vkFreeMemory(_accumMemory); + _api.vkDestroyImageView(_depthView); _api.vkDestroyImage(_depthImage); _api.vkFreeMemory(_depthMemory); + } + + private void SetFullViewport() + { + // Negative-height viewport (Vulkan 1.1) flips Y to match Bliss's Cam3D projection. Set once; + // it persists across the render passes in this command buffer. + var vp = new VkViewport { x = 0, y = _height, width = _width, height = -(float)_height, minDepth = 0, maxDepth = 1 }; + var sc = new VkRect2D { offset = default, extent = new VkExtent2D { width = _width, height = _height } }; + _api.vkCmdSetViewport(_cmd, 0, 1, &vp); + _api.vkCmdSetScissor(_cmd, 0, 1, &sc); + } + + // Re-recorded every frame (implicit reset via the pool's ResetCommandBuffer flag) with only the + // frustum-visible draws. Safe because Frame() waits the fence before the next record. + // Per-frame call counts, surfaced as profiler counters: they are what says whether "Vk Record" is + // dominated by draws or by material switches, which decide entirely different fixes. + private int _statBinds, _statDraws; + + private void RecordCommands() + { + _statBinds = 0; _statDraws = 0; + Check(_api.vkResetCommandBuffer(_cmd, VkCommandBufferResetFlags.None), "vkResetCommandBuffer"); + var begin = new VkCommandBufferBeginInfo { flags = VkCommandBufferUsageFlags.OneTimeSubmit }; + Check(_api.vkBeginCommandBuffer(_cmd, &begin), "vkBeginCommandBuffer"); + SetFullViewport(); + + var area = new VkRect2D { offset = default, extent = new VkExtent2D { width = _width, height = _height } }; + + // Pass 1: OPAQUE -> colour + depth. + VkClearValue* clearsO = stackalloc VkClearValue[2]; + clearsO[0] = new VkClearValue { color = new VkClearColorValue(ClearColour.X, ClearColour.Y, ClearColour.Z, ClearColour.W) }; + clearsO[1] = new VkClearValue { depthStencil = new VkClearDepthStencilValue(1f, 0) }; + var rpO = new VkRenderPassBeginInfo { renderPass = _rpOpaque, framebuffer = _fbOpaque[_slot], renderArea = area, clearValueCount = 2, pClearValues = clearsO }; + _api.vkCmdBeginRenderPass(_cmd, &rpO, VkSubpassContents.Inline); + BindLitState(); + // Everything that writes depth goes first - Opaque + Cutout, then Soft-Edge's alpha-tested + // DEPTH PREPASS (colour writes off) so its geometry occludes correctly, then the foliage + // billboards. Only then Additive, which is depth-tested but does NOT write, so it has to see + // the finished depth buffer. Volume wireframes last. Over-blending is the WBOIT pass below. + DrawVisible(_pipelineOpaque, _visibleOpaque, _visOpaqueCount); + DrawVisible(_pipelineSoftEdgeDepth, _visibleSoftEdge, _visSoftCount, softEdgeDepthPrepass: true); + DrawVisible(_pipelineBillboard, _visibleBillboard, _visBillCount); + DrawVisible(_pipelineAdditive, _visibleAdditive, _visAddCount); + DrawVolumes(); + _api.vkCmdEndRenderPass(_cmd); + + // Pass 2: ACCUMULATE translucent -> accum (=0) + reveal (=1), depth-tested (no write). + VkClearValue* clearsA = stackalloc VkClearValue[2]; + clearsA[0] = new VkClearValue { color = new VkClearColorValue(0f, 0f, 0f, 0f) }; + clearsA[1] = new VkClearValue { color = new VkClearColorValue(1f, 0f, 0f, 0f) }; + var rpA = new VkRenderPassBeginInfo { renderPass = _rpAccum, framebuffer = _fbAccum, renderArea = area, clearValueCount = 2, pClearValues = clearsA }; + _api.vkCmdBeginRenderPass(_cmd, &rpA, VkSubpassContents.Inline); + BindLitState(); + // Over-blended modes (Overlay/Scunge/Blended) plus Soft-Edge's colour pass - all order- + // independent through WBOIT, which also gives Blended its back-to-front result for free. + DrawVisible(_pipelineAccum, _visibleTranslucent, _visTransCount); + DrawVisible(_pipelineAccum, _visibleSoftEdge, _visSoftCount); + _api.vkCmdEndRenderPass(_cmd); + + // Pass 3: RESOLVE -> composite over the opaque colour (fullscreen triangle). + var rpR = new VkRenderPassBeginInfo { renderPass = _rpResolve, framebuffer = _fbResolve[_slot], renderArea = area, clearValueCount = 0, pClearValues = null }; + _api.vkCmdBeginRenderPass(_cmd, &rpR, VkSubpassContents.Inline); + _api.vkCmdBindPipeline(_cmd, VkPipelineBindPoint.Graphics, _pipelineResolve); + VkDescriptorSet rset = _resolveSet; + _api.vkCmdBindDescriptorSets(_cmd, VkPipelineBindPoint.Graphics, _resolveLayout, 0, 1, &rset, 0, null); + _api.vkCmdDraw(_cmd, 3, 1, 0, 0); + DrawSelectionOutline(); + DrawDebugLines(); + _api.vkCmdEndRenderPass(_cmd); + + Check(_api.vkEndCommandBuffer(_cmd), "vkEndCommandBuffer"); + } + + // Draws the given ordered visible-index list (indices into the sorted static arrays), binding each + // material's set + push constant once per run. firstInstance stays the STATIC index so it still + // addresses that instance's transform in the (whole, unculled) SSBO. + /// Uploads this frame's debug segments. Each entry is one line; the buffer grows to the + /// high-water mark. Call before ; passing nothing clears the overlay. + public void SetDebugLines(IReadOnlyList<(Vector3 a, Vector3 b, Vector4 color)> lines) + { + // Rewrites a buffer the in-flight frame may be reading, and can destroy it outright to grow it. + // Called only when the overlay changes, so draining first costs nothing. + WaitForPendingFrames(); + _debugVertexCount = lines.Count * 2; + if (_debugVertexCount == 0) return; + + if (_debugVertexCount > _debugLineCapacity) + { + if (_debugLineBuffer.Handle != 0) + { + _api.vkDeviceWaitIdle(); + _api.vkDestroyBuffer(_debugLineBuffer); + _api.vkFreeMemory(_debugLineMemory); + } + _debugLineCapacity = Math.Max(_debugVertexCount, 1024); + ulong size = (ulong)(_debugLineCapacity * DebugLineFloats * sizeof(float)); + (_debugLineBuffer, _debugLineMemory) = CreateBuffer(size, VkBufferUsageFlags.VertexBuffer, VkMemoryPropertyFlags.HostVisible | VkMemoryPropertyFlags.HostCoherent); + void* p; Check(_api.vkMapMemory(_debugLineMemory, 0, size, 0, &p), "vkMapMemory(debugLines)"); + _debugLineMapped = p; + } + + var dst = (float*)_debugLineMapped; + int o = 0; + foreach (var (a, b, colour) in lines) + { + dst[o + 0] = a.X; dst[o + 1] = a.Y; dst[o + 2] = a.Z; + dst[o + 3] = colour.X; dst[o + 4] = colour.Y; dst[o + 5] = colour.Z; dst[o + 6] = colour.W; + dst[o + 7] = b.X; dst[o + 8] = b.Y; dst[o + 9] = b.Z; + dst[o + 10] = colour.X; dst[o + 11] = colour.Y; dst[o + 12] = colour.Z; dst[o + 13] = colour.W; + o += DebugLineFloats * 2; + } + } + + private void DrawDebugLines() + { + if (_debugVertexCount == 0) return; + _api.vkCmdBindPipeline(_cmd, VkPipelineBindPoint.Graphics, _pipelineDebugLines); + VkDescriptorSet s0 = _descSet; + _api.vkCmdBindDescriptorSets(_cmd, VkPipelineBindPoint.Graphics, _outlineLayout, 0, 1, &s0, 0, null); + VkBuffer vb = _debugLineBuffer; ulong offset = 0; + _api.vkCmdBindVertexBuffers(_cmd, 0, 1, &vb, &offset); + _api.vkCmdDraw(_cmd, (uint)_debugVertexCount, 1, 0, 0); + } + + // Stencil mask-and-inflate rim around the selected entity's own instances, drawn last in the + // resolve pass (so it is never tinted by glass in front of it) using the scene's own vertex/index + // buffers and transform SSBO - the selected entity is already in the retained scene, so this costs + // two extra draws per instance and no extra upload. + private void DrawSelectionOutline() + { + if (_selected is null || !_ownerInstances.TryGetValue(_selected, out var owned) || owned.Length == 0) return; + + VkDescriptorSet s0 = _descSet; + _api.vkCmdBindDescriptorSets(_cmd, VkPipelineBindPoint.Graphics, _outlineLayout, 0, 1, &s0, 0, null); + VkBuffer vb = _vertexBuffer; ulong offset = 0; + _api.vkCmdBindVertexBuffers(_cmd, 0, 1, &vb, &offset); + _api.vkCmdBindIndexBuffer(_cmd, _indexBuffer, 0, VkIndexType.Uint32); + + // Pass 1 stamps the whole selection's footprint before pass 2 reads it, so a multi-mesh entity + // does not outline the seams between its own parts. + Vector4* pc = stackalloc Vector4[2]; + pc[0] = _outlineColor; + for (int pass = 0; pass < 2; pass++) + { + _api.vkCmdBindPipeline(_cmd, VkPipelineBindPoint.Graphics, pass == 0 ? _pipelineOutlineMask : _pipelineOutlineRim); + pc[1] = new Vector4(pass == 0 ? 0f : _outlineThickness, 0f, 0f, 0f); + _api.vkCmdPushConstants(_cmd, _outlineLayout, VkShaderStageFlags.Vertex | VkShaderStageFlags.Fragment, 0, 32, pc); + foreach (int i in owned) + _api.vkCmdDrawIndexed(_cmd, _drawIndexCount[i], 1, _drawFirstIndex[i], _drawVertexOffset[i], (uint)i); + } + } + + // Binds everything the lit draws share: the scene descriptor set (camera UBO + transform SSBO + + // light UBO + cubemap) and the merged scene vertex/index buffers. Called at the start of EVERY pass + // that issues lit draws rather than once per command buffer, because DrawVolumes and the outline + // bind through their own pipeline layouts - whose push-constant ranges differ from the lit layout's, + // which under Vulkan's layout-compatibility rules invalidates the lit descriptor set bindings too. + private void BindLitState() + { + VkDescriptorSet set0 = _descSet; + _api.vkCmdBindDescriptorSets(_cmd, VkPipelineBindPoint.Graphics, _layout, 0, 1, &set0, 0, null); + VkBuffer vb = _vertexBuffer; ulong offset = 0; + _api.vkCmdBindVertexBuffers(_cmd, 0, 1, &vb, &offset); + _api.vkCmdBindIndexBuffer(_cmd, _indexBuffer, 0, VkIndexType.Uint32); + } + + // Draws the per-frame volume list as depth-tested wireframe cubes (line list), one push constant + // (box matrix + colour) each. Cheap (few volumes); provided fresh each frame so selection/edits show. + private void DrawVolumes() + { + if (_volumeCount == 0) return; + _api.vkCmdBindPipeline(_cmd, VkPipelineBindPoint.Graphics, _volumePipeline); + VkDescriptorSet s0 = _descSet; + _api.vkCmdBindDescriptorSets(_cmd, VkPipelineBindPoint.Graphics, _volumeLayout, 0, 1, &s0, 0, null); + VkBuffer evb = _edgeVertexBuffer; ulong offset = 0; + _api.vkCmdBindVertexBuffers(_cmd, 0, 1, &evb, &offset); + _api.vkCmdBindIndexBuffer(_cmd, _edgeIndexBuffer, 0, VkIndexType.Uint32); + byte* pc = stackalloc byte[80]; + for (int i = 0; i < _volumeCount; i++) + { + var v = _volumes[i]; + *(Matrix4x4*)pc = v.world; + *(Vector4*)(pc + 64) = v.color; + _api.vkCmdPushConstants(_cmd, _volumeLayout, VkShaderStageFlags.Vertex | VkShaderStageFlags.Fragment, 0, 80, pc); + _api.vkCmdDrawIndexed(_cmd, _edgeIndexCount, 1, 0, 0, 0); + } + } + + private void DrawVisible(VkPipeline pipeline, int[] visible, int count, bool softEdgeDepthPrepass = false) + { + if (count == 0) return; + _api.vkCmdBindPipeline(_cmd, VkPipelineBindPoint.Graphics, pipeline); + int boundMat = -1; + _statDraws += count; + for (int k = 0; k < count; k++) + { + int i = visible[k]; + if (_drawMatSlot[i] != boundMat) + { + boundMat = _drawMatSlot[i]; + _statBinds++; + VkDescriptorSet ms = _matSets[boundMat]; + _api.vkCmdBindDescriptorSets(_cmd, VkPipelineBindPoint.Graphics, _layout, 1, 1, &ms, 0, null); + var pc0 = _matPC0[boundMat]; + // Soft-Edge pass 1 clips at 128/255 (the material's stored ref is pass 2's 4/255). + if (softEdgeDepthPrepass) pc0.W = 128f / 255f; + Vector4* pc = stackalloc Vector4[2] { pc0, new Vector4(_matRenderMode[boundMat], _matVertexAlpha[boundMat], _lit ? 1f : 0f, _matAlbedoHasAlpha[boundMat]) }; + _api.vkCmdPushConstants(_cmd, _layout, VkShaderStageFlags.Fragment, 0, 32, pc); + } + _api.vkCmdDrawIndexed(_cmd, _drawIndexCount[i], 1, _drawFirstIndex[i], _drawVertexOffset[i], (uint)i); + } + } + + /// Records and submits one frame. is the entity to outline + /// (null for none) - it is matched by reference against the owners captured with the instances. + public void Frame(Matrix4x4 view, Matrix4x4 projection, LightData light, + IReadOnlyList<(Matrix4x4 world, Vector4 color, uint pickId)> volumes, float volumeThickness, + object? selected = null, Vector4 outlineColor = default, float outlineThickness = 0.004f, + Vector3 cameraPosition = default, bool mobyDistanceCulling = false, bool lit = true, + bool frustumCulling = true, SceneEntityKind kinds = SceneEntityKind.All, + TextureFiltering filtering = TextureFiltering.Bilinear) + { + ApplyFiltering(filtering); + _cmd = _cmds[_slot]; + _descSet = _descSets[_slot]; + Matrix4x4 viewProj = view * projection; + var ub = (Matrix4x4*)_ubMappings[_slot]; + ub[0] = viewProj; ub[1] = view; ub[2] = projection; + _selected = selected; + if (outlineColor != default) _outlineColor = outlineColor; + _outlineThickness = outlineThickness; + _cameraPosition = cameraPosition; + _mobyDistanceCulling = mobyDistanceCulling; + _frustumCulling = frustumCulling; + _kindMask = (byte)kinds; + _lit = lit; + *(LightData*)_lightMappings[_slot] = light; + _volumes = volumes; + _volumeCount = volumes.Count; + if (volumeThickness != _edgeThickness) WriteEdgeVertices(volumeThickness); + + // Frustum-cull (threaded) then re-record only the visible draws. Safe to reset/re-record the + // command buffer here: the previous frame's fence wait (below) already drained the GPU. + using (Diagnostics.FrameProfiler.Sample("Vk Cull")) + { + ExtractFrustumPlanes(viewProj); + _visOpaqueCount = CullRange(0, _overStart, _visibleOpaque); + _visTransCount = CullRange(_overStart, _addStart, _visibleTranslucent); + _visAddCount = CullRange(_addStart, _softStart, _visibleAdditive); + _visSoftCount = CullRange(_softStart, _billStart, _visibleSoftEdge); + _visBillCount = CullRange(_billStart, _instanceCount, _visibleBillboard); + } + using (Diagnostics.FrameProfiler.Sample("Vk Record")) + RecordCommands(); + _recorded = true; + + // The frame is now RECORDED but not submitted - SubmitFrame does that, after the swapchain + // present. Everything between that submit and the wait below runs while the GPU works: the + // event pump, ImGui's own frame, this view's culling, and this record. That overlap is the + // whole point, and it is why the wait is here rather than straight after a submit. + // + // "Vk GPU Wait" is what is left over: how much longer the GPU needed than the CPU took to get + // back here. At a balanced split it should fall towards zero. + int previous = _slot ^ 1; + if (_pending[previous]) + { + using (Diagnostics.FrameProfiler.Sample("Vk GPU Wait")) + { + VkFence fence = _fences[previous]; + _api.vkWaitForFences(1, &fence, true, ulong.MaxValue); + _api.vkResetFences(1, &fence); + } + _pending[previous] = false; + // Finished, so it is the one ImGui can safely sample this frame. + _displaySlot = previous; + _ctx.BackendInfo.OverrideImageLayout(_colorTex[previous], (uint)VkImageLayout.ShaderReadOnlyOptimal); + } + else if (!_everSubmitted) + { + // First frame: there is no previous result to show, and an unrendered target is undefined + // memory. Submit and wait inline just this once so the viewport starts on a real image. + // The slot must be captured BEFORE submitting: SubmitFrame advances _slot, so reading it + // afterwards waits on a fence nothing was ever submitted with, which blocks forever. + int submitted = _slot; + SubmitFrame(); + VkFence fence = _fences[submitted]; + _api.vkWaitForFences(1, &fence, true, ulong.MaxValue); + _api.vkResetFences(1, &fence); + _pending[submitted] = false; + _displaySlot = submitted; + _ctx.BackendInfo.OverrideImageLayout(_colorTex[submitted], (uint)VkImageLayout.ShaderReadOnlyOptimal); + } + + if (!_loggedInit) { _loggedInit = true; Console.WriteLine($"[VkRenderer] frame 1 visible - {_visOpaqueCount} opaque/cutout, {_visTransCount} over-blended, {_visAddCount} additive, {_visSoftCount} soft-edge, {_visBillCount} foliage. Recorded {_statDraws} draws with {_statBinds} material binds."); } + Diagnostics.FrameProfiler.SetCounter("Vk material binds", _statBinds); + Diagnostics.FrameProfiler.SetCounter("Vk frames in flight", Frames); + Diagnostics.FrameProfiler.SetCounter("Vk visible draws", _visOpaqueCount + _visTransCount + _visAddCount + _visSoftCount + _visBillCount); + Diagnostics.FrameProfiler.SetCounter("Vk total draws", _instanceCount); + } + + /// Submits the frame recorded, and moves to the other slot. + /// + /// Called AFTER the swapchain present, deliberately. The host does a full device wait before + /// presenting (see Window.Draw), so a scene submitted before it would simply be drained there and + /// nothing would overlap. Submitting after means the GPU works through the scene while the CPU + /// starts the next frame. + public void SubmitFrame() + { + // Nothing recorded since the last submit: the view did not draw this frame (collapsed, closed, + // or the renderer was rebuilt). Resubmitting a stale buffer would just burn GPU time. + if (!_recorded || _pending[_slot]) return; + _recorded = false; + + VkCommandBuffer cmd = _cmds[_slot]; + var submit = new VkSubmitInfo { commandBufferCount = 1, pCommandBuffers = &cmd }; + Check(_api.vkQueueSubmit(_ctx.GraphicsQueue, 1, &submit, _fences[_slot]), "vkQueueSubmit"); + _pending[_slot] = true; + _everSubmitted = true; + _submits++; + _slot ^= 1; + } + + /// Blocks until nothing this renderer submitted is still running. Anything that mutates + /// state the GPU reads outside a recorded command buffer has to call this first. + private void WaitForPendingFrames() + { + for (int f = 0; f < Frames; f++) + { + if (!_pending[f]) continue; + VkFence fence = _fences[f]; + _api.vkWaitForFences(1, &fence, true, ulong.MaxValue); + _api.vkResetFences(1, &fence); + _pending[f] = false; + } + } + + /// GPU colour-ID pick at a viewport pixel. Returns the id of the entity under the cursor, + /// or . + /// + /// Rather than rasterizing the level to resolve a handful of pixels, the view-projection is + /// post-multiplied by a clip-space window that expands just the pick window to fill NDC. That both + /// shrinks the target to a few pixels square AND gives a narrow frustum whose planes reject + /// everything that cannot be under the cursor, so only a handful of draws are ever issued. + /// + /// Synchronous: it submits its own command buffer and waits. That is fine on a click, and the GPU + /// is idle at this point anyway because Frame already waited on its fence. + public uint Pick(Matrix4x4 view, Matrix4x4 projection, int mouseX, int mouseY, float viewportWidth, float viewportHeight) + { + if (viewportWidth <= 0f || viewportHeight <= 0f) return NoHit; + + // Clip-space window: scale/offset so the PickWindowPixels-square region around the cursor fills + // NDC. Y is inverted because screen y runs down while NDC y runs up (the same mapping the rest + // of the editor's screen-space maths uses). + float halfW = PickWindowPixels / viewportWidth; + float halfH = PickWindowPixels / viewportHeight; + float centreX = 2f * (mouseX + 0.5f) / viewportWidth - 1f; + float centreY = 1f - 2f * (mouseY + 0.5f) / viewportHeight; + var window = Matrix4x4.Identity; + window.M11 = 1f / halfW; + window.M22 = 1f / halfH; + window.M41 = -centreX / halfW; + window.M42 = -centreY / halfH; + + // The scene may still be running (frames are in flight), and this both writes into the slot's + // uniform buffer and shares the transform SSBO with it. Drain first - a click can afford it. + WaitForPendingFrames(); + + Matrix4x4 pickViewProj = (view * projection) * window; + // Billboards add their corner offset in VIEW space (see BillboardVertexGlsl), so the windowing + // has to be folded into the projection alone rather than the combined view+projection above - + // PickBillboardVertexGlsl applies uView itself, then this in place of the main pass's uProj. + Matrix4x4 pickBillboardProj = projection * window; + var ub = (Matrix4x4*)_ubMappings[_displaySlot]; + ub[3] = pickViewProj; + ub[4] = pickBillboardProj; + _descSet = _descSets[_displaySlot]; + ExtractPlanes(pickViewProj, _pickPlanes); + + Check(_api.vkResetCommandBuffer(_pickCmd, VkCommandBufferResetFlags.None), "vkResetCommandBuffer(pick)"); + var begin = new VkCommandBufferBeginInfo { flags = VkCommandBufferUsageFlags.OneTimeSubmit }; + Check(_api.vkBeginCommandBuffer(_pickCmd, &begin), "vkBeginCommandBuffer(pick)"); + + var vp = new VkViewport { x = 0, y = PickTargetSize, width = PickTargetSize, height = -(float)PickTargetSize, minDepth = 0, maxDepth = 1 }; + var sc = new VkRect2D { offset = default, extent = new VkExtent2D { width = PickTargetSize, height = PickTargetSize } }; + _api.vkCmdSetViewport(_pickCmd, 0, 1, &vp); + _api.vkCmdSetScissor(_pickCmd, 0, 1, &sc); + + VkClearValue* clears = stackalloc VkClearValue[2]; + clears[0] = new VkClearValue { color = new VkClearColorValue(1f, 1f, 1f, 1f) }; // decodes to NoHit + clears[1] = new VkClearValue { depthStencil = new VkClearDepthStencilValue(1f, 0) }; + var area = new VkRect2D { offset = default, extent = new VkExtent2D { width = PickTargetSize, height = PickTargetSize } }; + var rp = new VkRenderPassBeginInfo { renderPass = _rpPick, framebuffer = _fbPick, renderArea = area, clearValueCount = 2, pClearValues = clears }; + _api.vkCmdBeginRenderPass(_pickCmd, &rp, VkSubpassContents.Inline); + + VkDescriptorSet set0 = _descSet; + _api.vkCmdBindDescriptorSets(_pickCmd, VkPipelineBindPoint.Graphics, _pickLayout, 0, 1, &set0, 0, null); + + byte* pc = stackalloc byte[80]; + *(Matrix4x4*)pc = Matrix4x4.Identity; + + // Scene geometry. Every non-billboard bucket is walked, including the two the visible lists + // overlap on (Soft-Edge) - drawing an instance twice is harmless here, both draws write the + // same id. Billboards are excluded: PickVertexGlsl transforms inPos alone, and every corner of + // a foliage card shares the same anchor position, so it would draw a zero-area triangle - + // they get their own pipeline/loop below instead. + _api.vkCmdBindPipeline(_pickCmd, VkPipelineBindPoint.Graphics, _pipelinePick); + VkBuffer vb = _vertexBuffer; ulong offset = 0; + _api.vkCmdBindVertexBuffers(_pickCmd, 0, 1, &vb, &offset); + _api.vkCmdBindIndexBuffer(_pickCmd, _indexBuffer, 0, VkIndexType.Uint32); + for (int i = 0; i < _billStart; i++) + { + if (_instPickId[i] == NoHit) continue; + if (!InPickFrustum(i)) continue; + *(uint*)(pc + 64) = _instPickId[i]; + _api.vkCmdPushConstants(_pickCmd, _pickLayout, VkShaderStageFlags.Vertex | VkShaderStageFlags.Fragment, 0, 80, pc); + _api.vkCmdDrawIndexed(_pickCmd, _drawIndexCount[i], 1, _drawFirstIndex[i], _drawVertexOffset[i], (uint)i); + } + + // Foliage billboards: same shared vertex/index buffers, but PickBillboardVertexGlsl so the + // card's corner offset gets applied in view space before projecting, same as the visible pass. + if (_billStart < _instanceCount) + { + _api.vkCmdBindPipeline(_pickCmd, VkPipelineBindPoint.Graphics, _pipelinePickBillboard); + for (int i = _billStart; i < _instanceCount; i++) + { + if (_instPickId[i] == NoHit) continue; + if (!InPickFrustum(i)) continue; + *(uint*)(pc + 64) = _instPickId[i]; + _api.vkCmdPushConstants(_pickCmd, _pickLayout, VkShaderStageFlags.Vertex | VkShaderStageFlags.Fragment, 0, 80, pc); + _api.vkCmdDrawIndexed(_pickCmd, _drawIndexCount[i], 1, _drawFirstIndex[i], _drawVertexOffset[i], (uint)i); + } + } + + // Volume edges, so trigger volumes stay selectable - same thin-box geometry the wireframe uses, + // which is what makes the pick target match what the user sees exactly. + if (_volumeCount > 0) + { + _api.vkCmdBindPipeline(_pickCmd, VkPipelineBindPoint.Graphics, _pipelinePickVolume); + VkBuffer evb = _edgeVertexBuffer; ulong eoffset = 0; + _api.vkCmdBindVertexBuffers(_pickCmd, 0, 1, &evb, &eoffset); + _api.vkCmdBindIndexBuffer(_pickCmd, _edgeIndexBuffer, 0, VkIndexType.Uint32); + for (int i = 0; i < _volumeCount; i++) + { + var v = _volumes[i]; + if (v.pickId == NoHit) continue; + *(Matrix4x4*)pc = v.world; + *(uint*)(pc + 64) = v.pickId; + _api.vkCmdPushConstants(_pickCmd, _pickLayout, VkShaderStageFlags.Vertex | VkShaderStageFlags.Fragment, 0, 80, pc); + _api.vkCmdDrawIndexed(_pickCmd, _edgeIndexCount, 1, 0, 0, 0); + } + } + + _api.vkCmdEndRenderPass(_pickCmd); + + var copy = new VkBufferImageCopy + { + bufferOffset = 0, bufferRowLength = 0, bufferImageHeight = 0, + imageSubresource = new VkImageSubresourceLayers { aspectMask = VkImageAspectFlags.Color, mipLevel = 0, baseArrayLayer = 0, layerCount = 1 }, + imageOffset = default, + imageExtent = new VkExtent3D { width = PickTargetSize, height = PickTargetSize, depth = 1 }, + }; + _api.vkCmdCopyImageToBuffer(_pickCmd, _pickImage, VkImageLayout.TransferSrcOptimal, _pickReadback, 1, ©); + Check(_api.vkEndCommandBuffer(_pickCmd), "vkEndCommandBuffer(pick)"); + + VkCommandBuffer cmd = _pickCmd; + var submit = new VkSubmitInfo { commandBufferCount = 1, pCommandBuffers = &cmd }; + Check(_api.vkQueueSubmit(_ctx.GraphicsQueue, 1, &submit, _pickFence), "vkQueueSubmit(pick)"); + VkFence fence = _pickFence; + _api.vkWaitForFences(1, &fence, true, ulong.MaxValue); + _api.vkResetFences(1, &fence); + + // Nearest hit to the centre of the window wins: a click a pixel or two off a thin silhouette + // should still select the object rather than miss it. + var px = (byte*)_pickReadbackMapped; + uint best = NoHit; + int bestDistSq = int.MaxValue; + const int centre = (int)PickTargetSize / 2; + for (int y = 0; y < PickTargetSize; y++) + { + for (int x = 0; x < PickTargetSize; x++) + { + byte* t = px + ((y * (int)PickTargetSize + x) * 4); + uint id = (uint)(t[0] | (t[1] << 8) | (t[2] << 16) | (t[3] << 24)); + if (id == NoHit) continue; + int dx = x - centre, dy = y - centre; + int distSq = dx * dx + dy * dy; + if (distSq < bestDistSq) { bestDistSq = distSq; best = id; } + } + } + return best; + } + + private bool InPickFrustum(int i) + { + Vector3 c = _instCenter[i]; float r = _instRadius[i]; + for (int p = 0; p < 6; p++) + { + Vector4 pl = _pickPlanes[p]; + if (pl.X * c.X + pl.Y * c.Y + pl.Z * c.Z + pl.W < -r) return false; + } + return true; + } + + /// Rewrites one entity's world matrices in the transform SSBO (and its culling sphere + /// centre), so an editor move/rotate/scale shows immediately without rebuilding the scene. The + /// matrices must arrive in the same order Entity.GetRenderablesForVk produced them, which is + /// the order they were captured in. Returns false if the entity is not part of the captured scene. + public bool UpdateEntityTransforms(object owner, IReadOnlyList worlds, Vector4 worldBoundingSphere) + { + if (!_ownerInstances.TryGetValue(owner, out var owned) || owned.Length != worlds.Count) return false; + + // Written straight into the transform SSBO, which is SHARED across frames in flight, so a write + // here can land while the GPU is reading it for the previous frame. Deliberately not guarded by + // a fence wait, because this is called every frame for the selection and waiting would undo the + // whole overlap. It is safe in practice because of the equality check below: the value only + // actually changes while the gizmo is being dragged, and the worst case then is one frame in + // which the dragged entity reads a half-updated matrix - invisible mid-drag, and gone the next + // frame. Every other frame writes nothing at all. + var dst = (Matrix4x4*)_tbMapped; + for (int k = 0; k < owned.Length; k++) + { + int i = owned[k]; + if (dst[i] != worlds[k]) dst[i] = worlds[k]; + _instCenter[i] = new Vector3(worldBoundingSphere.X, worldBoundingSphere.Y, worldBoundingSphere.Z); + _instRadius[i] = worldBoundingSphere.W; + } + return true; + } + + // Six frustum planes (left,right,bottom,top,near,far) in world space from the row-vector viewProj + // (Gribb-Hartmann; D3D/Vulkan clip with z in [0,1]). Plane (a,b,c,d): a*x+b*y+c*z+d >= 0 is inside. + private void ExtractFrustumPlanes(Matrix4x4 m) => ExtractPlanes(m, _planes); + + private static void ExtractPlanes(Matrix4x4 m, Vector4[] planes) + { + planes[0] = NormalizePlane(new Vector4(m.M14 + m.M11, m.M24 + m.M21, m.M34 + m.M31, m.M44 + m.M41)); + planes[1] = NormalizePlane(new Vector4(m.M14 - m.M11, m.M24 - m.M21, m.M34 - m.M31, m.M44 - m.M41)); + planes[2] = NormalizePlane(new Vector4(m.M14 + m.M12, m.M24 + m.M22, m.M34 + m.M32, m.M44 + m.M42)); + planes[3] = NormalizePlane(new Vector4(m.M14 - m.M12, m.M24 - m.M22, m.M34 - m.M32, m.M44 - m.M42)); + planes[4] = NormalizePlane(new Vector4(m.M13, m.M23, m.M33, m.M43)); + planes[5] = NormalizePlane(new Vector4(m.M14 - m.M13, m.M24 - m.M23, m.M34 - m.M33, m.M44 - m.M43)); + } + + private static Vector4 NormalizePlane(Vector4 p) + { + float len = new Vector3(p.X, p.Y, p.Z).Length(); + return len > 0f ? p / len : p; + } + + // Culls [lo,hi) of the sorted instances into outBuf (ordered, indices preserved). Threaded for large + // ranges: each chunk compacts into its own disjoint region of _cullScratch (no locks), then the + // per-chunk runs are concatenated in order - so material-run batching in DrawVisible still holds. + private int CullRange(int lo, int hi, int[] outBuf) + { + int n = hi - lo; + if (n <= 0) return 0; + if (n < 4096) + { + int c = 0; + for (int i = lo; i < hi; i++) if (Visible(i)) outBuf[c++] = i; + return c; + } + int threads = Math.Min(_chunkCounts.Length, Math.Max(2, n / 4096)); + int chunkLen = (n + threads - 1) / threads; + System.Threading.Tasks.Parallel.For(0, threads, t => + { + int s = lo + t * chunkLen; int e = Math.Min(s + chunkLen, hi); + int w = s; + for (int i = s; i < e; i++) if (Visible(i)) _cullScratch[w++] = i; + _chunkCounts[t] = w - s; + }); + int total = 0; + for (int t = 0; t < threads; t++) + { + int s = lo + t * chunkLen; + Array.Copy(_cullScratch, s, outBuf, total, _chunkCounts[t]); + total += _chunkCounts[t]; + } + return total; + } + + private bool Visible(int i) + { + // Per-type visibility first: it is a single mask test, and a type switched off is switched off + // whatever the camera is doing. + if ((_instKind[i] & _kindMask) == 0) return false; + + Vector3 c = _instCenter[i]; float r = _instRadius[i]; + // The game's own per-moby display distance, checked before the frustum planes: it rejects far + // more instances than the frustum does on a dense level, and it is a single squared compare. + if (_mobyDistanceCulling) + { + float d = _instDisplayDist[i]; + if (d >= 0f && Vector3.DistanceSquared(c, _cameraPosition) > d * d) return false; + } + if (!_frustumCulling) return true; + for (int p = 0; p < 6; p++) + { + Vector4 pl = _planes[p]; + if (pl.X * c.X + pl.Y * c.Y + pl.Z * c.Z + pl.W < -r) return false; + } + return true; + } + + public void Resize(GraphicsDevice gd, uint width, uint height) + { + width = Math.Max(width, 1u); height = Math.Max(height, 1u); + if (width == _width && height == _height) return; + WaitForPendingFrames(); + _api.vkDeviceWaitIdle(); + DestroyTargets(); + _width = width; _height = height; + CreateTargets(gd); + // Both colour targets are new and hold undefined memory, so the next Frame() has to go through + // the first-frame path again rather than presenting one of them as a finished image. + _slot = 0; + _displaySlot = 0; + _everSubmitted = false; + } + + private (VkBuffer, VkDeviceMemory) CreateBuffer(ulong size, VkBufferUsageFlags usage, VkMemoryPropertyFlags props) + { + var info = new VkBufferCreateInfo { size = size, usage = usage, sharingMode = VkSharingMode.Exclusive }; + VkBuffer buffer; Check(_api.vkCreateBuffer(&info, &buffer), "vkCreateBuffer"); + VkMemoryRequirements reqs; _api.vkGetBufferMemoryRequirements(buffer, &reqs); + VkDeviceMemory memory = Allocate(reqs, props); + Check(_api.vkBindBufferMemory(buffer, memory, 0), "vkBindBufferMemory"); + return (buffer, memory); + } + + private VkDeviceMemory Allocate(VkMemoryRequirements reqs, VkMemoryPropertyFlags required) + { + VkPhysicalDeviceMemoryProperties memProps; + _ctx.InstanceApi.vkGetPhysicalDeviceMemoryProperties(_ctx.PhysicalDevice, &memProps); + uint typeIndex = uint.MaxValue; + for (uint i = 0; i < memProps.memoryTypeCount; i++) + if ((reqs.memoryTypeBits & (1u << (int)i)) != 0 && (memProps.memoryTypes[(int)i].propertyFlags & required) == required) { typeIndex = i; break; } + if (typeIndex == uint.MaxValue) throw new InvalidOperationException($"[VkRenderer] No memory type for {required}."); + var allocInfo = new VkMemoryAllocateInfo { allocationSize = reqs.size, memoryTypeIndex = typeIndex }; + VkDeviceMemory memory; Check(_api.vkAllocateMemory(&allocInfo, &memory), "vkAllocateMemory"); + return memory; + } + + private static void Check(VkResult result, string what) + { + if (result != VkResult.Success) throw new InvalidOperationException($"[VkRenderer] {what} -> {result}"); + } + + public void Dispose() + { + WaitForPendingFrames(); + _api.vkDeviceWaitIdle(); + DestroyTargets(); + _api.vkDestroyImageView(_envCubeView); + foreach (var v in _texViews) _api.vkDestroyImageView(v); + _api.vkDestroySampler(_samplerPoint); + _api.vkDestroySampler(_samplerLinear); + _api.vkDestroyPipeline(_pipelineOpaque); + _api.vkDestroyPipeline(_pipelineAccum); + _api.vkDestroyPipeline(_pipelineResolve); + _api.vkDestroyFence(_pickFence); + _api.vkDestroyFramebuffer(_fbPick); + _api.vkDestroyImageView(_pickDepthView); _api.vkDestroyImage(_pickDepthImage); _api.vkFreeMemory(_pickDepthMemory); + _api.vkDestroyImageView(_pickView); _api.vkDestroyImage(_pickImage); _api.vkFreeMemory(_pickMemory); + _api.vkDestroyBuffer(_pickReadback); _api.vkFreeMemory(_pickReadbackMemory); + _api.vkDestroyRenderPass(_rpPick); + _api.vkDestroyPipelineLayout(_pickLayout); + _api.vkDestroyPipeline(_pipelinePickVolume); + _api.vkDestroyPipeline(_pipelinePickBillboard); + _api.vkDestroyPipeline(_pipelinePick); + _api.vkDestroyPipeline(_pipelineDebugLines); + if (_debugLineBuffer.Handle != 0) { _api.vkDestroyBuffer(_debugLineBuffer); _api.vkFreeMemory(_debugLineMemory); } + _api.vkDestroyPipeline(_pipelineBillboard); + _api.vkDestroyPipeline(_pipelineOutlineRim); + _api.vkDestroyPipeline(_pipelineOutlineMask); + _api.vkDestroyPipeline(_volumePipeline); + _api.vkDestroyPipeline(_pipelineSoftEdgeDepth); + _api.vkDestroyPipeline(_pipelineAdditive); + _api.vkDestroyPipelineLayout(_layout); + _api.vkDestroyPipelineLayout(_resolveLayout); + _api.vkDestroyPipelineLayout(_outlineLayout); + _api.vkDestroyPipelineLayout(_volumeLayout); + _api.vkDestroyShaderModule(_vs); + _api.vkDestroyShaderModule(_fsOpaque); + _api.vkDestroyShaderModule(_fsAccum); + _api.vkDestroyShaderModule(_fsAdditive); + _api.vkDestroyShaderModule(_resolveVs); + _api.vkDestroyShaderModule(_resolveFs); + _api.vkDestroyShaderModule(_volumeVs); + _api.vkDestroyShaderModule(_volumeFs); + _api.vkDestroyShaderModule(_debugLineVs); + _api.vkDestroyShaderModule(_debugLineFs); + _api.vkDestroyShaderModule(_pickVs); + _api.vkDestroyShaderModule(_pickVolumeVs); + _api.vkDestroyShaderModule(_pickBillboardVs); + _api.vkDestroyShaderModule(_pickFs); + _api.vkDestroyShaderModule(_billboardVs); + _api.vkDestroyShaderModule(_billboardFs); + _api.vkDestroyShaderModule(_outlineVs); + _api.vkDestroyShaderModule(_outlineFs); + _api.vkDestroyBuffer(_edgeVertexBuffer); _api.vkFreeMemory(_edgeVbMemory); + _api.vkDestroyBuffer(_edgeIndexBuffer); _api.vkFreeMemory(_edgeIbMemory); + _api.vkDestroyRenderPass(_rpOpaque); + _api.vkDestroyRenderPass(_rpAccum); + _api.vkDestroyRenderPass(_rpResolve); + _api.vkDestroyDescriptorPool(_descPool); + if (_ownsEnvCube) _envCube.Dispose(); + _api.vkDestroyDescriptorSetLayout(_matSetLayout); + _api.vkDestroyDescriptorSetLayout(_descLayout); + _api.vkDestroyDescriptorSetLayout(_resolveSetLayout); + _api.vkDestroyBuffer(_transformBuffer); _api.vkFreeMemory(_tbMemory); + for (int f = 0; f < Frames; f++) + { + _api.vkDestroyBuffer(_lightBuffers[f]); _api.vkFreeMemory(_lightMemories[f]); + _api.vkDestroyBuffer(_uniformBuffers[f]); _api.vkFreeMemory(_ubMemories[f]); + } + _api.vkDestroyBuffer(_indexBuffer); _api.vkFreeMemory(_ibMemory); + _api.vkDestroyBuffer(_vertexBuffer); _api.vkFreeMemory(_vbMemory); + for (int f = 0; f < Frames; f++) _api.vkDestroyFence(_fences[f]); + _api.vkDestroyCommandPool(_pool); + } +} diff --git a/ReLunacy.Engine/Rendering/Vulkan/VulkanSceneCapture.cs b/ReLunacy.Engine/Rendering/Vulkan/VulkanSceneCapture.cs new file mode 100644 index 0000000..89417d6 --- /dev/null +++ b/ReLunacy.Engine/Rendering/Vulkan/VulkanSceneCapture.cs @@ -0,0 +1,137 @@ +using System.Runtime.CompilerServices; +using ReLunacy.Engine.Rendering.Resources; +using Veldrith; + +namespace ReLunacy.Engine.Rendering.Vulkan; + +/// One material's inputs for the raw-Vulkan lit renderer: the five sampled textures (the +/// Veldrith textures behind the material's maps) plus the per-material scalars the shader needs - the baked +/// flag, parallax scale/bias, the alpha-clip threshold, and a render mode (1 = cutout/alpha-clip). +/// Textures may be null (the renderer falls back to a real texture). +public struct VkMaterialDesc +{ + public Texture? Albedo; + public Texture? Normal; + public Texture? Props; + public Texture? LightColour; + public Texture? LightDir; + public float HasBaked; + public float ParallaxScale; + public float ParallaxBias; + public float AlphaThreshold; + /// The GAME's rendering mode (0 Opaque, 1 Overlay, 2 Additive, 3 Scunge, 4 Cutout, + /// 5 Soft-Edge, 6 Blended) - the renderer implements each one's real RSX blend/depth/alpha state. + /// See IMaterial.GameRenderMode and dev/chatgpt-eboot-{1,2,3}.txt. + public float GameRenderMode; + public float UsesVertexAlpha; // 1 = this material has decoded per-vertex alpha to contribute + // 1 = the albedo's own alpha channel is real (not a format with no alpha bits at all), so it's + // meaningful to fold into the final opacity alongside vertex alpha rather than being garbage. + public float AlbedoHasAlphaChannel; + /// 1 = foliage: the geometry packs a shared anchor into every vertex position and the + /// card's corner offset into the lightmap UV slot, so it needs the billboard vertex shader rather + /// than the lit one. This is a property of the GEOMETRY, not of the shader the material came from, + /// which is why EntityFoliage opts in explicitly (see AssetManager.GetOrBuildBillboardMaterial). + public float IsBillboard; +} + +/// Which kind of scene object an instance came from, so the Render menu's per-type toggles can +/// filter draws without rebuilding the scene. Derived from the owning entity when the renderer is built; +/// anything with no owner (the asset preview) is and always drawn. +[Flags] +public enum SceneEntityKind : byte +{ + None = 0, + Moby = 1, + Tie = 2, + UFrag = 4, + Foliage = 8, + Other = 16, + All = Moby | Tie | UFrag | Foliage | Other, +} + +/// The game's rendering modes (ShaderMetadataOld 0x11), with the RSX states the EBOOT reverse +/// established for each (dev/chatgpt-eboot-{1,2,3}.txt). Overlay/Scunge/Blended all alpha-blend but are +/// NOT interchangeable; Additive is SrcAlpha/One (adds light); Soft-Edge is genuinely two passes. +public enum GameRenderMode : byte +{ + Opaque = 0, // blend off, depth write on, no alpha test + Overlay = 1, // SrcAlpha/OneMinusSrcAlpha, no depth write, polygon offset (decal) + Additive = 2, // SrcAlpha/One, no depth write + Scunge = 3, // SrcAlpha/OneMinusSrcAlpha, no depth write + Cutout = 4, // depth write on, alpha test GEQUAL 128/255 + SoftEdge = 5, // pass 1: depth-only prepass, alpha test ~128/255; pass 2: blended, alpha test 4/255 + Blended = 6, // SrcAlpha/OneMinusSrcAlpha, no depth write, sorted back-to-front +} + +/// Geometry registry bridging the asset system to the from-scratch renderer (Docs/NewRenderer.md, +/// Stage 11+). A RenderMesh is plain data with no GPU buffers of its own, so the renderer uploads the +/// geometry itself out of here. AssetManager (and EntityUFrag, and EntityFoliage) register EVERY mesh +/// they build, keyed by that mesh instance. At scene-assembly time each scene instance's mesh (from +/// Entity.GetRenderablesForVk) is resolved back to its geometry, so all placements of one model share a +/// single uploaded geometry. Vertex data is interleaved +/// pos(3)+uv(2)+normal(3)+tangent(4)+uv2(2)+color(4) = 18 floats/vertex - tangent (handedness in .w) +/// feeds normal mapping, uv2 is the lightmap UV set, and color is the per-vertex colour/alpha (Stage +/// 14). The eventual renderer will capture this through a proper geometry-upload subsystem. +public static class VulkanSceneCapture +{ + /// Floats per vertex in : position xyz, texcoord uv, normal xyz, + /// tangent xyzw (w = bitangent handedness), lightmap texcoord uv2, colour rgba. + public const int FloatsPerVertex = 18; + + /// RenderMesh instance -> index into /. + /// Reference-keyed so every placement of a model (the same mesh) maps to ONE geometry. + public static readonly Dictionary MeshToGeometry = new(ReferenceEqualityComparer.Instance); + + /// Interleaved vertex data per registered geometry (8 floats/vertex - see FloatsPerVertex). + public static readonly List VertexData = new(); + + /// 32-bit triangle-list indices per registered geometry (local to that geometry). + public static readonly List Indices = new(); + + /// Packs a Vertex3D[] into the raw-Vulkan renderer's interleaved layout: position + /// xyz, texcoord uv, normal xyz (see FloatsPerVertex for the full layout). + public static float[] Interleave(Vertex3D[] vertices) + { + var data = new float[vertices.Length * FloatsPerVertex]; + for (int v = 0; v < vertices.Length; v++) + { + int o = v * FloatsPerVertex; + data[o + 0] = vertices[v].Position.X; + data[o + 1] = vertices[v].Position.Y; + data[o + 2] = vertices[v].Position.Z; + data[o + 3] = vertices[v].TexCoords.X; + data[o + 4] = vertices[v].TexCoords.Y; + data[o + 5] = vertices[v].Normal.X; + data[o + 6] = vertices[v].Normal.Y; + data[o + 7] = vertices[v].Normal.Z; + data[o + 8] = vertices[v].Tangent.X; + data[o + 9] = vertices[v].Tangent.Y; + data[o + 10] = vertices[v].Tangent.Z; + data[o + 11] = vertices[v].Tangent.W; + data[o + 12] = vertices[v].TexCoords2.X; + data[o + 13] = vertices[v].TexCoords2.Y; + data[o + 14] = vertices[v].Color.X; + data[o + 15] = vertices[v].Color.Y; + data[o + 16] = vertices[v].Color.Z; + data[o + 17] = vertices[v].Color.W; + } + return data; + } + + public static void Register(object mesh, float[] vertexData, uint[] indices) + { + if (MeshToGeometry.ContainsKey(mesh)) return; + MeshToGeometry[mesh] = VertexData.Count; + VertexData.Add(vertexData); + Indices.Add(indices); + } + + public static bool TryGet(object mesh, out int geometryIndex) => MeshToGeometry.TryGetValue(mesh, out geometryIndex); + + public static void Clear() + { + MeshToGeometry.Clear(); + VertexData.Clear(); + Indices.Clear(); + } +} diff --git a/ReLunacy.Engine/Scene/Entity.cs b/ReLunacy.Engine/Scene/Entity.cs index 5776268..3a16987 100644 --- a/ReLunacy.Engine/Scene/Entity.cs +++ b/ReLunacy.Engine/Scene/Entity.cs @@ -1,10 +1,5 @@ using System.Numerics; -using Bliss.CSharp.Camera.Dim3; -using Bliss.CSharp.Colors; -using Bliss.CSharp.Graphics.Rendering.Renderers; -using Bliss.CSharp.Graphics.Rendering.Renderers.Forward; -using Bliss.CSharp.Transformations; -using Veldrith; +using ReLunacy.Engine.Rendering.Resources; namespace ReLunacy.Engine.Scene; @@ -17,29 +12,51 @@ public abstract class Entity : IDisposable public bool allowRender = true; public bool selected = false; - private Transform transform; + private Transform transform = new(); public Transform Transform { get => transform; set { transform = value; IsDirty = true; } } - /// Bounding sphere in LOCAL space — center is an offset from this entity's own pivot, not an absolute world position. Set once at load and never needs updating when the entity moves; see for the world-space value used by culling/rendering. + /// Bounding sphere in LOCAL space - center is an offset from this entity's own pivot, not an absolute world position. Set once at load and never needs updating when the entity moves; see for the world-space value used by culling/rendering. public abstract Vector4 BoundingSphere { get; set; } - /// Current world-space bounding sphere, tracking live — always reflects the entity's current position, including mid-drag via the gizmo. + /// Current world-space bounding sphere, tracking live, including + /// mid-drag via the gizmo. This is what the renderer frustum-culls against. + /// + /// The local centre goes through the WHOLE transform, not just its translation. It is an offset in + /// the entity's own space, so a rotated entity whose model origin is not at its centre needs that + /// offset rotated with it, and a scaled one needs it scaled. Adding the raw offset to the + /// translation (what this used to do) left the sphere in the wrong place for every rotated tie and + /// every moby placed with a scale, which showed up as geometry vanishing while still on screen. + /// + /// The radius scales by the LARGEST absolute scale component: a sphere under non-uniform scale is + /// bounded by one of radius r * max|s|, and over-estimating only costs a few draws that survive the + /// cull, where under-estimating clips something the camera can see. public Vector4 WorldBoundingSphere { get { - var worldCenter = Transform.Translation + new Vector3(BoundingSphere.X, BoundingSphere.Y, BoundingSphere.Z); - return new Vector4(worldCenter, BoundingSphere.W); + var transform = Transform; + var localCenter = new Vector3(BoundingSphere.X, BoundingSphere.Y, BoundingSphere.Z); + var worldCenter = Vector3.Transform(localCenter, transform.GetMatrix()); + float maxScale = MathF.Max( + MathF.Abs(transform.Scale.X), + MathF.Max(MathF.Abs(transform.Scale.Y), MathF.Abs(transform.Scale.Z))); + return new Vector4(worldCenter, BoundingSphere.W * maxScale * BoundingSphereMargin); } } public abstract string Name { get; protected set; } public bool IsDirty { get; set; } = true; + /// Slack on the culling radius. Measuring every instance's transformed vertices against + /// its own sphere on metropolis put the tightest ties, mobys and UFrags at 1.000 to 1.006 of it, so + /// the assets' own fitted radii are very slightly optimistic. A sliver of geometry outside the + /// sphere is a visible pop at the screen edge; a 2% larger sphere is a handful of extra draws. + private const float BoundingSphereMargin = 1.02f; + protected List cachedRenderables = []; protected Entity() @@ -47,43 +64,41 @@ protected Entity() ID = EntityIndex++; } - public abstract void Draw(IRenderer renderer, OutputDescription outputDescription, CommandList commandList, Cam3D camera, ImmediateRenderer immediateRenderer); + /// Rebuilds if . Every entity type + /// that caches renderables overrides this; Draw and GetRenderablesForVk both go through it. + /// + /// It has to be reachable from OUTSIDE Draw because the raw-Vulkan renderer never calls Draw. A + /// gizmo edit ASSIGNS a new Transform (GizmoController) rather than mutating the existing one, so + /// the Renderables built earlier keep pointing at the old Transform object and hand back stale + /// matrices until they are rebuilt. Leaving that rebuild inside Draw meant edits never reached the + /// VK path, and that the initial scene capture could only see entities that some earlier frame + /// happened to have drawn. + protected virtual void EnsureRenderables() { } - /// Meshes to draw for GPU picking, each with its own already-fully-world-baked - /// transform, all tagged with this entity's own ID — populated from the last Draw() call. A - /// Moby's bangles/submeshes all resolve back to the one Moby entity. Reads each Renderable's - /// OWN Transform(s) rather than this entity's Transform directly: every entity (Moby/Tie/UFrag, - /// and EntityVolume's 12 separate per-edge Renderables) constructs each Renderable with the - /// exact Transform that Renderable should be drawn/picked at, so this is just trusting that - /// directly instead of recomputing/assuming it's always equal to Entity.Transform — which lets - /// an entity with more than one Renderable (like EntityVolume) report each one's real world - /// position instead of collapsing them all onto one shared matrix. GetTransforms() returns a - /// capacity-sized backing array (rounded up to a power of two, padded with default Transforms - /// past the real count) — InstanceCount is the actual number of live entries, hence the - /// explicit bound below rather than trusting the span's own length; this matters even for a - /// non-instanced single-transform Renderable in principle, and is essential the moment - /// anything in this codebase uses real GPU instancing (useInstancing: true) again. Materializes - /// into a List rather than using yield return because ReadOnlySpan<Transform> can't be - /// held live across a yield boundary. - public IEnumerable<(Bliss.CSharp.Geometry.Meshes.IMesh mesh, Matrix4x4 world)> GetPickableMeshes() + /// Every mesh this entity places, with the material that placement is drawn with. + /// + /// The material is per-RENDERABLE, not per-mesh, which is what lit ties need: one tie model is + /// shared across many placements, but each placement's baked lightmap lives on its own material + /// (EntityTie builds new Renderable(mesh, Transform, perInstanceMaterial)). + /// Rebuilds the cache first (see ) so it never depends on anything + /// else having run this frame. + public virtual IEnumerable<(RenderMesh mesh, RenderMaterial material, Matrix4x4 world, Vector4 sphere)> GetRenderablesForVk() { - var results = new List<(Bliss.CSharp.Geometry.Meshes.IMesh, Matrix4x4)>(); + EnsureRenderables(); + // The game's own per-entity world bounding sphere (xyz centre, w radius). Shared across the + // entity's renderables, so the renderer culls at entity granularity like the game rather than + // from a looser per-mesh AABB sphere. + var sphere = WorldBoundingSphere; + var results = new List<(RenderMesh, RenderMaterial, Matrix4x4, Vector4)>(); foreach (var renderable in cachedRenderables) { var transforms = renderable.GetTransforms(); - int count = (int)renderable.InstanceCount; + int count = renderable.InstanceCount; for (int i = 0; i < count; i++) - results.Add((renderable.Mesh, transforms[i].GetMatrix())); + results.Add((renderable.Mesh, renderable.Material, transforms[i].GetMatrix(), sphere)); } return results; } - public virtual void DrawBoundingSphere(OutputDescription outputDescription, CommandList commandList, ImmediateRenderer immediateRenderer) - { - var sphere = WorldBoundingSphere; - var center = new Vector3(sphere.X, sphere.Y, sphere.Z); - immediateRenderer.DrawSphereWires(new Transform { Translation = center }, sphere.W, 8, 8, Color.Cyan); - } - public virtual void Dispose() { } } diff --git a/ReLunacy.Engine/Scene/EntityCluster.cs b/ReLunacy.Engine/Scene/EntityCluster.cs index fc8112b..b46002f 100644 --- a/ReLunacy.Engine/Scene/EntityCluster.cs +++ b/ReLunacy.Engine/Scene/EntityCluster.cs @@ -1,7 +1,4 @@ using System.Diagnostics.CodeAnalysis; -using Bliss.CSharp.Camera.Dim3; -using Bliss.CSharp.Graphics.Rendering.Renderers; -using Bliss.CSharp.Graphics.Rendering.Renderers.Forward; using ReLunacy.Engine.Assets.Interfaces; using ReLunacy.Engine.Assets.LevelElements; using ReLunacy.Engine.Rendering; @@ -47,13 +44,13 @@ public void Add(IPlacedInstance tieInstance) public void Add(IUFrag ufrag, GraphicsDevice gd) { TotalEntities++; - Entities.Add(new EntityUFrag(gd, ufrag, _assetManager)); + Entities.Add(new EntityUFrag(ufrag, _assetManager)); } public void Add(Volume volume, GraphicsDevice gd) { TotalEntities++; - Entities.Add(new EntityVolume(volume, gd)); + Entities.Add(new EntityVolume(volume)); } public bool TryGetEntity(int id, [NotNullWhen(true)] out Entity? entity) @@ -62,16 +59,6 @@ public bool TryGetEntity(int id, [NotNullWhen(true)] out Entity? entity) return entity != null; } - public void Draw(IRenderer renderer, OutputDescription od, CommandList cl, Cam3D camera, ImmediateRenderer immediateRenderer) - { - if (!allowRender) return; - - foreach (var e in Entities) - { - e.Draw(renderer, od, cl, camera, immediateRenderer); - } - } - public void Dispose() { foreach (var e in Entities) e.Dispose(); diff --git a/ReLunacy.Engine/Scene/EntityFoliage.cs b/ReLunacy.Engine/Scene/EntityFoliage.cs new file mode 100644 index 0000000..649c0ab --- /dev/null +++ b/ReLunacy.Engine/Scene/EntityFoliage.cs @@ -0,0 +1,139 @@ +using System.Numerics; +using ReLunacy.Engine.Rendering.Resources; +using ReLunacy.Engine.Assets.Interfaces; +using ReLunacy.Engine.Rendering; +using Veldrith; + +namespace ReLunacy.Engine.Scene; + +/// One placement of a foliage asset: a batch of camera-facing sprite cards. +/// +/// The geometry is built ONCE, not per frame. Every vertex stores the card's ANCHOR as its +/// position and its own 2D corner offset in TexCoords2; BillboardModelShaderSource does the +/// facing by adding that offset after the view transform. So the vertex buffer is static and the +/// cards still turn with the camera - no per-frame rebuild, no CPU billboarding. +/// +/// Only ONE sprite LOD is built (the highest-detail one). The LOD chain is a distance-switching +/// mechanism and drawing every level at once stacks 117 cards where the game draws 58; wiring the +/// switch needs the LOD distances in Loading.Objects.FoliageSpriteLodRange, which are read but not +/// yet acted on. +public class EntityFoliage : Entity +{ + public readonly Assets.Foliage.Foliage BaseFoliage; + + public override Vector4 BoundingSphere { get; set; } + public override string Name { get; protected set; } + + private readonly RenderMesh? _mesh; + private readonly RenderMaterial? _material; + + /// Which sprite LOD this entity draws. 0 is the densest set. + public const int BuiltLod = 0; + + public EntityFoliage(Assets.Foliage.Foliage foliage, in Assets.Foliage.FoliagePlacement placement, + IMaterial? material, AssetManager assetManager) + { + BaseFoliage = foliage; + + Matrix4x4.Decompose(placement.Transform, out var scale, out var rotation, out var translation); + Transform = new Transform { Translation = translation, Rotation = rotation, Scale = scale }; + + // BoundingSphere is LOCAL per Entity's convention, i.e. in the space the card anchors are in. + // The placement record's sphere is WORLD-space, so the whole placement transform is undone + // rather than just its translation: subtracting the translation alone left the sphere rotated + // and scaled wrongly about the placement, which culled foliage that was still in frame. + var centre = new Vector3(placement.BoundingSphere.X, placement.BoundingSphere.Y, placement.BoundingSphere.Z); + float radius = placement.BoundingSphere.W; + float maxScale = MathF.Max(MathF.Abs(scale.X), MathF.Max(MathF.Abs(scale.Y), MathF.Abs(scale.Z))); + BoundingSphere = radius > 0f && maxScale > 0f && Matrix4x4.Invert(placement.Transform, out var toLocal) + ? new Vector4(Vector3.Transform(centre, toLocal), radius / maxScale) + // ComputeLocalBounds already works in card-anchor space, so it needs no conversion. + : ComputeLocalBounds(foliage); + + Name = $"{foliage.Name}_{ID}"; + + var cards = foliage.SpritesForLod(BuiltLod).ToList(); + if (cards.Count == 0) return; + + // material carries the atlas resolved from FoliageMetadata.TextureIndex — a DIRECT index + // into the 0x5200 texture table, proven by the game's own A200 loader (see that field). It + // is null only when the asset's index is the 0xFFFFFFFF sentinel or the level is new-engine; + // GetOrBuildBillboardMaterial then falls back to the default white texture, which still + // shows the billboarding and card geometry while making an unresolved case obvious on screen. + _material = assetManager.GetOrBuildBillboardMaterial(material); + _mesh = BuildMesh(cards, _material); + } + + /// Two triangles per card, sharing the anchor as every corner's position. The corner + /// order in the file is already a consistent winding around the quad (0,1,2,3), so the two + /// triangles are 0-1-2 and 0-2-3. + private static RenderMesh BuildMesh(List cards, RenderMaterial material) + { + var vertices = new Vertex3D[cards.Count * 4]; + var indices = new uint[cards.Count * 6]; + + for (int c = 0; c < cards.Count; c++) + { + var card = cards[c]; + for (int k = 0; k < 4; k++) + { + vertices[c * 4 + k] = new Vertex3D( + card.Anchor, + card.Uvs[k], + card.CornerOffsets[k], + Vector3.UnitY, + new Vector4(1f, 0f, 0f, 1f), + Vector4.One); + } + + int v = c * 4; + int i = c * 6; + indices[i + 0] = (uint)(v + 0); + indices[i + 1] = (uint)(v + 1); + indices[i + 2] = (uint)(v + 2); + indices[i + 3] = (uint)(v + 0); + indices[i + 4] = (uint)(v + 2); + indices[i + 5] = (uint)(v + 3); + } + + var mesh = new RenderMesh(vertices, indices, material); + + // Foliage builds its mesh here rather than through AssetManager.BuildModel, so it has to + // register its own geometry with the capture registry - otherwise the scene walk finds no + // geometry for it and foliage silently never renders (the same gap EntityUFrag had). + Rendering.Vulkan.VulkanSceneCapture.Register(mesh, Rendering.Vulkan.VulkanSceneCapture.Interleave(vertices), indices); + + return mesh; + } + + /// Fallback bounds from the cards themselves, used when the instance record's radius + /// is zero. Card offsets are added in view space so they can point any direction in world + /// space - the anchor spread is padded by the largest corner offset rather than assuming the + /// cards lie in some plane. + private static Vector4 ComputeLocalBounds(Assets.Foliage.Foliage foliage) + { + if (foliage.Sprites.Count == 0) return Vector4.Zero; + + var min = new Vector3(float.MaxValue); + var max = new Vector3(float.MinValue); + float pad = 0f; + foreach (var card in foliage.Sprites) + { + min = Vector3.Min(min, card.Anchor); + max = Vector3.Max(max, card.Anchor); + foreach (var o in card.CornerOffsets) pad = MathF.Max(pad, o.Length()); + } + + var centre = (min + max) * 0.5f; + return new Vector4(centre, (max - centre).Length() + pad); + } + + protected override void EnsureRenderables() + { + if (!IsDirty || _mesh == null) return; + cachedRenderables.Clear(); + cachedRenderables.Add(new Renderable(_mesh, Transform)); + IsDirty = false; + } + +} diff --git a/ReLunacy.Engine/Scene/EntityManager.cs b/ReLunacy.Engine/Scene/EntityManager.cs index 059c005..a70781b 100644 --- a/ReLunacy.Engine/Scene/EntityManager.cs +++ b/ReLunacy.Engine/Scene/EntityManager.cs @@ -1,8 +1,6 @@ using System.Numerics; -using Bliss.CSharp.Camera.Dim3; -using Bliss.CSharp.Graphics.Rendering.Renderers; -using Bliss.CSharp.Graphics.Rendering.Renderers.Forward; using ReLunacy.Engine.Assets.Levels; +using ReLunacy.Engine.Diagnostics; using ReLunacy.Engine.Rendering; using Veldrith; @@ -18,29 +16,34 @@ public class EntityManager : IDisposable public bool renderMobys = true; public bool renderTies = true; public bool renderUFrags = true; + public bool renderFoliage = true; public bool renderVolumes = true; + /// NOT CURRENTLY DRAWN. The wireframe spheres were an ImmediateRenderer overlay inside + /// each entity's Bliss Draw, which no longer exists - the raw-Vulkan renderer owns the view and has + /// no debug-shape pass yet. The flag and its menu item are kept so re-adding one is a local change. public bool renderBoundingSpheres = false; public bool FrustumCullingEnabled = true; - /// Skip drawing Mobys past their in-game display distance (read from the level's own - /// gameplay data). Defaults OFF, unlike — the game only - /// relies on a short display distance because its camera stays near the player, but the - /// editor's free-fly camera has no such guarantee, so a real, fairly common in-game value - /// (many old-engine instances sit around 64 units) reads as "this Moby just isn't loading" the - /// moment the camera is anywhere else. Toggle on from the Render menu when specifically - /// checking what the game itself would render at the current camera position. - public bool MobyDistanceCullingEnabled = false; + /// Skip drawing Mobys past their in-game display distance (the per-instance display_dist + /// read from the level's own gameplay data - see MobyInstanceOld/New, normalized so <=0 = unlimited + /// in RegionReader). Defaults ON: it matches what the game actually renders and is the single + /// biggest lever on Moby draw-call count, which dominates the CPU-bound scene-record cost on dense + /// levels. The trade-off is the editor's free-fly camera - the game keeps display distances short + /// because its camera hugs the player, so flying far from / high above the level culls Mobys that + /// would be visible in-game only from up close. Toggle off from the Render menu for a full-level + /// overview. + public bool MobyDistanceCullingEnabled = true; /// Absolute world-unit thickness of the edge geometry EntityVolume builds (see - /// Primitives.CreateWireEdge) — the same for every volume regardless of its own size. This - /// same geometry is both the visible wireframe box and its own GPU pick target — a solid pick + /// EntityVolume.RecomputeEdgeTransforms): the same for every volume regardless of its own size. This + /// same geometry is both the visible wireframe box and its own GPU pick target - a solid pick /// hitbox would make clicking anywhere inside a (often large) volume select it instead of /// whatever's actually behind the click, so picking is scoped to near the edges, same as what's /// actually drawn. Kept on EntityManager rather than read directly from EditorSettings because - /// ReLunacy.Engine has no reference to the app project — View3D syncs this from + /// ReLunacy.Engine has no reference to the app project - View3D syncs this from /// Program.Settings.VolumeWireThickness every frame, same pattern as Camera.FarPlane. public float VolumeWireThickness = 0.1f; /// RGBA (0-1 per channel, matching ImGui's ColorEdit4) tint for a Volume's wireframe /// box, unselected/selected. Synced from Program.Settings by View3D every frame, same reason - /// and pattern as — EntityVolume converts these to Bliss's + /// and pattern as - EntityVolume converts these to Bliss's /// byte-channel Color when (re)building its shared tint materials. public Vector4 VolumeColor = new(1f, 1f, 0f, 1f); public Vector4 VolumeSelectedColor = new(1f, 1f, 1f, 1f); @@ -51,20 +54,36 @@ public class EntityManager : IDisposable public int UFragsCount => Regions.Sum(r => r.UFragsCount); public int ZonesCount => Regions.Sum(r => r.ZonesCount); + /// Foliage placements, flat rather than under a region: foliage lives in its own + /// asset/instance sections with no zone or region membership recorded anywhere in the file, so + /// inventing a parent would be a guess. See Loading.Readers.FoliageReader. + public List Foliage { get; } = []; + public void LoadRegion(Region? region, AssetManager am, GraphicsDevice gd) { if (region is null) return; Regions.Add(new EntityRegion(region, am, gd)); } - public void Draw(IRenderer renderer, OutputDescription od, CommandList cl, Cam3D camera, ImmediateRenderer immediateRenderer) + public void LoadFoliage(IReadOnlyList foliages, AssetManager am, GraphicsDevice gd) { - foreach (var region in Regions) - region.Draw(renderer, od, cl, camera, immediateRenderer); + foreach (var foliage in foliages) + { + // foliage.Material is resolved from the asset's direct texture index (A200+0x08 → + // 0x5200 table, see FoliageMetadata.TextureIndex). Null (0xFFFFFFFF sentinel / new + // engine) falls back to the default billboard texture inside GetOrBuildBillboardMaterial. + foreach (var placement in foliage.Placements) + Foliage.Add(new EntityFoliage(foliage, placement, foliage.Material, am)); + } + + if (Foliage.Count != 0) + Console.WriteLine($"Foliage: {Foliage.Count} entity/entities built (LOD {EntityFoliage.BuiltLod})."); } public IEnumerable AllEntities() { + foreach (var e in Foliage) yield return e; + foreach (var region in Regions) { foreach (var e in region.MobyInstances.Entities) yield return e; diff --git a/ReLunacy.Engine/Scene/EntityMoby.cs b/ReLunacy.Engine/Scene/EntityMoby.cs index 5b5e81f..5a12015 100644 --- a/ReLunacy.Engine/Scene/EntityMoby.cs +++ b/ReLunacy.Engine/Scene/EntityMoby.cs @@ -1,10 +1,7 @@ using System.Numerics; -using Bliss.CSharp.Camera.Dim3; -using Bliss.CSharp.Geometry.Models; -using Bliss.CSharp.Graphics.Rendering.Renderers; -using Bliss.CSharp.Graphics.Rendering.Renderers.Forward; -using Bliss.CSharp.Transformations; +using ReLunacy.Engine.Rendering.Resources; using ReLunacy.Engine.Assets.Interfaces; +using ReLunacy.Engine.Diagnostics; using ReLunacy.Engine.Rendering; using Veldrith; @@ -14,20 +11,27 @@ public class EntityMoby : Entity { public readonly IMoby BaseMoby; public override string Name { get; protected set; } - public Model[]? Models { get; private set; } + public RenderModel[]? Models { get; private set; } public override Vector4 BoundingSphere { get; set; } - /// In-game display distance for this instance (units), < 0 = unlimited. Read straight from the level's own gameplay data — see MobyInstanceOld/New. - public float DisplayDistance { get; } + /// In-game display distance for this instance (units), < 0 = unlimited. Read straight from the level's own gameplay data - see MobyInstanceOld/New. + public float DisplayDistance { get; set; } + + /// In-game update distance for this instance (units), < 0 = unlimited - a separate + /// budget from that gates the game's own logic updates, not + /// rendering. Read straight from the level's own gameplay data - see MobyInstanceOld/New. Not + /// used by anything in ReLunacy's own rendering/culling; carried purely so it's visible and + /// editable in the Property Inspector, matching ReLunacy-Ymir. + public float UpdateDistance { get; set; } public EntityMoby(IPlacedInstance mobyInstance, AssetManager assetManager) { BaseMoby = mobyInstance.Asset; // Moby instance rotation is read straight from the file as ZYX Euler angles in radians - // (see MobyInstanceOld/New — no unit conversion happens at the read site). The original + // (see MobyInstanceOld/New - no unit conversion happens at the read site). The original // Lunacy app (Transform.SetRotation) builds this as Qz * Qy * Qx, i.e. rotate around X - // first, then Y, then Z — NOT the same composition as CreateFromYawPitchRoll(Y,X,Z), + // first, then Y, then Z - NOT the same composition as CreateFromYawPitchRoll(Y,X,Z), // which was verified (numerically, against an unambiguous row-vector reference matrix) // to produce a different rotation whenever X and Z are both non-zero. var rotationQuat = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, mobyInstance.Rotation.Z) @@ -45,6 +49,7 @@ public EntityMoby(IPlacedInstance mobyInstance, AssetManager assetManager BoundingSphere = new Vector4(center, radius); DisplayDistance = mobyInstance.DisplayDistance; + UpdateDistance = mobyInstance.UpdateDistance; Name = !string.IsNullOrEmpty(mobyInstance.Name) ? mobyInstance.Name.Split('/')[^1] : $"Moby_{BaseMoby.Id:X}_{mobyInstance.Group}"; @@ -52,35 +57,14 @@ public EntityMoby(IPlacedInstance mobyInstance, AssetManager assetManager Models = models; } - public override void Draw(IRenderer renderer, OutputDescription outputDescription, CommandList commandList, Cam3D camera, ImmediateRenderer immediateRenderer) + protected override void EnsureRenderables() { - if (!allowRender || !EntityManager.Singleton.renderMobys) return; - - var sphere = WorldBoundingSphere; - var sphereCenter = new Vector3(sphere.X, sphere.Y, sphere.Z); - if (EntityManager.Singleton.FrustumCullingEnabled && !camera.GetFrustum().ContainsSphere(sphereCenter, sphere.W)) return; - - // camera.Position is stored negated relative to world/entity positions (same convention - // used throughout the editor — see PropertyInspectorFrame's distance-to-entity readout). - if (EntityManager.Singleton.MobyDistanceCullingEnabled && DisplayDistance >= 0 && Vector3.Distance(sphereCenter, -camera.Position) > DisplayDistance) return; - - if (Models is null) return; - - if (EntityManager.Singleton.renderBoundingSpheres) - DrawBoundingSphere(outputDescription, commandList, immediateRenderer); - - if (IsDirty) - { - cachedRenderables.Clear(); - foreach (var model in Models) - foreach (var mesh in model.Meshes) - cachedRenderables.Add(new Renderable(mesh, Transform)); - IsDirty = false; - } - - foreach (var renderable in cachedRenderables) - renderer.DrawRenderable(renderable); - - EntitiesRenderedThisFrame++; + if (!IsDirty || Models is null) return; + cachedRenderables.Clear(); + foreach (var model in Models) + foreach (var mesh in model.Meshes) + cachedRenderables.Add(new Renderable(mesh, Transform)); + IsDirty = false; } + } diff --git a/ReLunacy.Engine/Scene/EntityRegion.cs b/ReLunacy.Engine/Scene/EntityRegion.cs index d5c8e76..f945ccb 100644 --- a/ReLunacy.Engine/Scene/EntityRegion.cs +++ b/ReLunacy.Engine/Scene/EntityRegion.cs @@ -1,6 +1,3 @@ -using Bliss.CSharp.Camera.Dim3; -using Bliss.CSharp.Graphics.Rendering.Renderers; -using Bliss.CSharp.Graphics.Rendering.Renderers.Forward; using ReLunacy.Engine.Assets.Levels; using ReLunacy.Engine.Rendering; using Veldrith; @@ -37,20 +34,6 @@ public EntityRegion(Region region, AssetManager assetManager, GraphicsDevice gd) Zones.Add(new EntityZone(zone, gd, assetManager)); } - public void Draw(IRenderer renderer, OutputDescription od, CommandList cl, Cam3D camera, ImmediateRenderer immediateRenderer) - { - if (!allowRender) return; - - if (EntityManager.Singleton.renderMobys) - MobyInstances.Draw(renderer, od, cl, camera, immediateRenderer); - - if (EntityManager.Singleton.renderVolumes) - Volumes.Draw(renderer, od, cl, camera, immediateRenderer); - - foreach (var z in Zones) - z.Draw(renderer, od, cl, camera, immediateRenderer); - } - public void Dispose() { MobyInstances.Dispose(); diff --git a/ReLunacy.Engine/Scene/EntityTie.cs b/ReLunacy.Engine/Scene/EntityTie.cs index d0a93fd..4d23332 100644 --- a/ReLunacy.Engine/Scene/EntityTie.cs +++ b/ReLunacy.Engine/Scene/EntityTie.cs @@ -1,9 +1,5 @@ using System.Numerics; -using Bliss.CSharp.Camera.Dim3; -using Bliss.CSharp.Geometry.Models; -using Bliss.CSharp.Graphics.Rendering.Renderers; -using Bliss.CSharp.Graphics.Rendering.Renderers.Forward; -using Bliss.CSharp.Transformations; +using ReLunacy.Engine.Rendering.Resources; using ReLunacy.Engine.Assets.Interfaces; using ReLunacy.Engine.Rendering; using Veldrith; @@ -17,7 +13,7 @@ public class EntityTie : Entity public override Vector4 BoundingSphere { get; set; } public override string Name { get; protected set; } - public Model? Model { get; private set; } + public RenderModel? Model { get; private set; } public EntityTie(IPlacedInstance tieInstance, AssetManager assetManager) { @@ -25,7 +21,7 @@ public EntityTie(IPlacedInstance tieInstance, AssetManager assetManager) // Ties are placed via a raw affine matrix read straight from the file. Decompose it // directly into translation/rotation/scale instead of going through IPlacedInstance's - // Euler-angle properties (Position/Rotation/Scale) — those are a lossy decompose-then- + // Euler-angle properties (Position/Rotation/Scale) - those are a lossy decompose-then- // recompose round trip through a custom quaternion->Euler conversion whose axis mapping // doesn't match System.Numerics' Quaternion.CreateFromYawPitchRoll, and they collapse // anisotropic scale into a single averaged float. Decomposing once here is exact. @@ -47,88 +43,57 @@ public EntityTie(IPlacedInstance tieInstance, AssetManager assetManager) Model = model; _assetManager = assetManager; - // Deliberately NOT tieInstance.LightmapIndex yet. Tie instances really do carry a bake - // index (1728 distinct on metropolis), but ties have no identified lightmap UV set — - // VertexFormat0 holds exactly one UV pair and it TILES, so sampling an atlas with it - // repeats the bake many times per mesh. Terrain has UFragVertex.UVs2 and works; ties wait - // until their UV source turns up. + // Baked lighting for this placement, gated on the ASSET actually having a lightmap UV set. + // The instance's own index is real either way (1728 distinct on metropolis), but binding a + // bake to a tie whose second UV channel fell back to the base UV would tile the bake across + // every mesh - visibly wrong, and wrong in a way that looks like a shading bug rather than a + // missing offset. Ties without the UV array therefore render unlit, exactly as before. // - // And it will not turn up inside VertexFormat0, which is why searching that record kept - // failing. The captured RPCS3 DrawParametersBuffer covers every draw in the frame, and the - // 85250 draws with a 20-byte vertex record (VertexFormat0's exact size) match it for - // attr0/attr1/attr2 at +0/+8/+12 - position+boneIndex, UVs, packed normal - and then carry - // EXTRA attributes that live in their own streams. The candidate is sharply located: 33103 - // draws in the frame bind an attribute of stride 4 holding 2 HALF FLOATS - a dedicated, - // tightly packed UV set outside the 20-byte record entirely - and it is at attribute - // LOCATION 4 in every single one of them (30197 of those are 20-byte records). That is the - // shape a lightmap UV channel has when it is bolted onto an existing vertex format without - // changing it, and a location that consistent is a convention, not a coincidence. + // The channel itself is settled. The game's tie vertex program (dev/ties/, and see + // Loading.Vertices.TieLightmapUV) routes RSX attribute location 4 - a stride-4 pair of half + // floats in its own stream, outside the 20-byte VertexFormat0 record - straight into tc0.zw, + // untransformed, which is where the tie fragment programs sample the baked light colour + // (tex4) and light direction (tex14). Ties and UFrags reach the bake identically; the older + // note here that ties must use "a different texcoord" was wrong. What differs per shader + // variant is only tc0's packing (the unlit tie variants read tc0.z as a lone scalar). // - // It also rules out reusing the terrain result directly. RPCS3 does manual vertex fetch, so - // one compiled vertex program serves any layout - meaning if ties shared terrain's program, - // terrain's answer (attr2 -> tc0.zw) would apply verbatim. It cannot: for 78752 of the 85250 - // 20-byte draws, attr2 is a CMP 11:11:10 at +12, i.e. VertexFormat0's packed NORMAL, not a UV - // pair. Sampling an atlas with that would be meaningless, so ties reach the bake through a - // different program and a different texcoord. - // Consistent with this on the file side: TieMetadataOld's field at 0x18 (named - // verticesBufferSize) is really the vertex buffer's END offset, not a size - the 0x18-0x14 - // span is divisible by 20 for 193/193 ties, so the vertex count is span/20. The gap between - // one tie's end and the next tie's start is divisible by 4 for 192/192, and equals exactly - // vertexCount*4 for 41 of them. Suggestive of a second stride-4 array packed between the - // vertex buffers, NOT yet proven - the ratio is inconsistent for the rest, so do not build - // on it until a tie drawcall's own vertex+fragment program confirms which attribute feeds - // the bake sampler. Ties use a different shader program than the captured terrain one, so - // the terrain result (attr2 -> tc0.zw) does not transfer. - LightmapIndex = Loading.Objects.Instances.TieInstance.NoLightmap; + // What is NOT settled is where that array lives for two thirds of ties - see + // TieLightmapUV.TryReadLightmapUVs' caller for the one location that is known. + LightmapIndex = BaseTie.GetLightmapUVs() is not null + ? tieInstance.LightmapIndex + : Loading.Objects.Instances.TieInstance.NoLightmap; } private readonly AssetManager _assetManager; - /// This placement's baked lighting entry, or 0xFFFF for none — see + /// This placement's baked lighting entry, or 0xFFFF for none - see /// IPlacedInstance.LightmapIndex. public ushort LightmapIndex { get; } - public override void Draw(IRenderer renderer, OutputDescription outputDescription, CommandList commandList, Cam3D camera, ImmediateRenderer immediateRenderer) + protected override void EnsureRenderables() { - if (!allowRender || !EntityManager.Singleton.renderTies) return; - - var sphere = WorldBoundingSphere; - var sphereCenter = new Vector3(sphere.X, sphere.Y, sphere.Z); - if (EntityManager.Singleton.FrustumCullingEnabled && !camera.GetFrustum().ContainsSphere(sphereCenter, sphere.W)) return; - - if (EntityManager.Singleton.renderBoundingSpheres) - DrawBoundingSphere(outputDescription, commandList, immediateRenderer); - - if (Model is null) return; - - if (IsDirty) + if (!IsDirty || Model is null) return; + cachedRenderables.Clear(); + // Baked lighting is per-PLACEMENT while the model (and its meshes' materials) is shared by + // every instance of this tie asset, so a lightmapped instance needs its own material. + // Renderable's material-override constructor gives us that without duplicating the + // mesh: the vertex/index buffers stay shared, only the material differs. Instances with + // no bake keep using the mesh's own material, so nothing extra is built for them. + bool lit = LightmapIndex != Loading.Objects.Instances.TieInstance.NoLightmap; + for (int i = 0; i < Model.Meshes.Length; i++) { - cachedRenderables.Clear(); - // Baked lighting is per-PLACEMENT while Model (and its meshes' materials) is shared by - // every instance of this tie asset, so a lightmapped instance needs its own Material. - // Renderable's material-override constructor gives us that without duplicating the - // mesh: the vertex/index buffers stay shared, only the material differs. Instances with - // no bake keep using the mesh's own material, so nothing extra is built for them. - bool lit = LightmapIndex != Loading.Objects.Instances.TieInstance.NoLightmap; - for (int i = 0; i < Model.Meshes.Length; i++) + var mesh = Model.Meshes[i]; + if (lit && i < BaseTie.Meshes.Count) { - var mesh = Model.Meshes[i]; - if (lit && i < BaseTie.Meshes.Count) - { - var perInstance = _assetManager.GetOrBuildMaterial(BaseTie.Meshes[i].Material, LightmapIndex); - cachedRenderables.Add(new Renderable(mesh, Transform, perInstance)); - } - else - { - cachedRenderables.Add(new Renderable(mesh, Transform)); - } + var perInstance = _assetManager.GetOrBuildMaterial(BaseTie.Meshes[i].Material, LightmapIndex); + cachedRenderables.Add(new Renderable(mesh, Transform, perInstance)); + } + else + { + cachedRenderables.Add(new Renderable(mesh, Transform)); } - IsDirty = false; } - - foreach (var renderable in cachedRenderables) - renderer.DrawRenderable(renderable); - - EntitiesRenderedThisFrame++; + IsDirty = false; } + } diff --git a/ReLunacy.Engine/Scene/EntityUFrag.cs b/ReLunacy.Engine/Scene/EntityUFrag.cs index d5ba0c3..05121ef 100644 --- a/ReLunacy.Engine/Scene/EntityUFrag.cs +++ b/ReLunacy.Engine/Scene/EntityUFrag.cs @@ -1,13 +1,5 @@ using System.Numerics; -using Bliss.CSharp; -using Bliss.CSharp.Camera.Dim3; -using Bliss.CSharp.Geometry.Meshes; -using Bliss.CSharp.Geometry.Meshes.Data; -using Bliss.CSharp.Graphics.Rendering.Renderers; -using Bliss.CSharp.Graphics.Rendering.Renderers.Forward; -using Bliss.CSharp.Graphics.VertexTypes; -using Bliss.CSharp.Materials; -using Bliss.CSharp.Transformations; +using ReLunacy.Engine.Rendering.Resources; using ReLunacy.Engine.Assets.Interfaces; using ReLunacy.Engine.Rendering; using Veldrith; @@ -16,56 +8,77 @@ namespace ReLunacy.Engine.Scene; public class EntityUFrag : Entity { + /// UFragVertex's raw x/y/z are fixed-point shorts quantised x256 on both engines, so the + /// mesh lives in that space and Transform.Scale divides it back out. + private const float UFragQuantisation = 256f; + public readonly IUFrag UFrag; public override Vector4 BoundingSphere { get; set; } public override string Name { get; protected set; } - public Mesh UFragMesh { get; protected set; } + public RenderMesh UFragMesh { get; protected set; } - public EntityUFrag(GraphicsDevice gd, IUFrag ufrag, AssetManager assetManager) + public EntityUFrag(IUFrag ufrag, AssetManager assetManager) { UFrag = ufrag; Name = !string.IsNullOrEmpty(ufrag.Name) ? $"{ufrag.Name}_{ID}" : $"UFrag_{ID}"; - var vertices = ConvertUFragToVertices(ufrag); + var vertices = ConvertUFragToVertices(ufrag, ufrag.Material.UsesVertexAlphaCandidate); var indices = ufrag.GetIndices(); // Passing the lightmap index is what makes UFrags sharing a shader but not a lightmap get - // distinct Materials — see AssetManager.GetOrBuildMaterial. Until the 0x5400/0x5410 + // distinct Materials - see AssetManager.GetOrBuildMaterial. Until the 0x5400/0x5410 // textures are actually built and bound this only splits the cache; it is the seam the // baked lighting hangs off, and getting it wrong later would silently give every UFrag one // shared lightmap. var material = assetManager.GetOrBuildMaterial(ufrag.Material, ufrag.LightmapIndex); - UFragMesh = new Mesh(gd, material, new BasicMeshData(vertices, indices)); + UFragMesh = new RenderMesh(vertices, indices, material); + + // New-renderer geometry registry (Stage 11+): UFrags build their mesh here rather than via + // AssetManager.BuildModel, so register it too or the raw-Vulkan renderer never sees UFrag + // geometry. Keyed by this mesh instance; interleaved pos+uv+normal. See VulkanSceneCapture. + if (vertices.Length > 0 && indices.Length >= 3) + Rendering.Vulkan.VulkanSceneCapture.Register(UFragMesh, Rendering.Vulkan.VulkanSceneCapture.Interleave(vertices), indices); - // UFragVertex's raw per-vertex x/y/z are fixed-point shorts quantized ×256 on BOTH - // engines (master's UFragVertex/OldUFragVertex structs are laid out identically — no - // engine-specific scale) — using Scale = Vector3.One for new engine was rendering every + // UFragVertex's raw per-vertex x/y/z are fixed-point shorts quantized x256 on BOTH + // engines (master's UFragVertex/OldUFragVertex structs are laid out identically - no + // engine-specific scale) - using Scale = Vector3.One for new engine was rendering every // chunk's mesh 256x too large relative to its own bounding radius. // GetAnchor() is the chunk's real placement anchor (world-space, already descaled - // per-engine by ZoneReader.ConvertUFrag) — local (0,0,0) of the mesh maps there. This is + // per-engine by ZoneReader.ConvertUFrag) - local (0,0,0) of the mesh maps there. This is // NOT the same as GetBoundingCenter(): that's the true bounding-sphere center, a separate, // non-grid-aligned value only used for culling below (see ZoneReader.ConvertUFrag for how // the two were previously conflated, causing per-chunk placement gaps). Formula is // adapted from the last confirmed-working implementation (master's Entity.cs, CZone.UFrag - // constructor) — rotation was investigated there and found to always be identity for + // constructor) - rotation was investigated there and found to always be identity for // these chunks. That reference also multiplied by a yard-to-meter constant, dropped here: // this session already found (and the user confirmed) that Ties/Mobys need no such // conversion, and terrain has to share the same world-unit space as the props sitting on it. var anchor = ufrag.GetAnchor(); - Transform = new Transform { Translation = anchor, Rotation = Quaternion.Identity, Scale = Vector3.One / 256f }; - // BoundingSphere is LOCAL space per Entity's convention (offset from Transform.Translation) - // — the true bounding-sphere center doesn't generally coincide with the placement anchor. - BoundingSphere = new Vector4(ufrag.GetBoundingCenter() - anchor, ufrag.GetBoundingRadius()); + Transform = new Transform { Translation = anchor, Rotation = Quaternion.Identity, Scale = Vector3.One / UFragQuantisation }; + // BoundingSphere is LOCAL space per Entity's convention: the space the mesh's own vertices are + // in, which for a UFrag is the x256 fixed-point space Transform.Scale undoes. The file gives + // both the centre and the radius in WORLD units, so both are multiplied back INTO that space + // here, and WorldBoundingSphere's own scaling takes them straight back out again. Storing the + // world values raw made the culling sphere 256x too small around a chunk that is metres across. + // The true bounding-sphere centre does not generally coincide with the placement anchor, which + // is why this is an offset at all. + BoundingSphere = new Vector4( + (ufrag.GetBoundingCenter() - anchor) * UFragQuantisation, + ufrag.GetBoundingRadius() * UFragQuantisation); } - private static Vertex3D[] ConvertUFragToVertices(IUFrag ufrag) + private static Vertex3D[] ConvertUFragToVertices(IUFrag ufrag, bool useVertexAlpha) { var positions = ufrag.GetVertexPositions(); var uvs = ufrag.GetTextureCoordinates(); var normals = ufrag.GetNormals(); var tangents = ufrag.GetTangents(); var lightmapUVs = ufrag.GetLightmapUVs(); + // See Material.UsesVertexAlphaCandidate / AssetManager.ConvertGeometryToVertices, which this + // mirrors: only read when the material has a use for it, same reason - a mesh not gated on + // this shouldn't pay for (or risk garbage from) a decode nothing downstream will read. + var vertexAlpha = useVertexAlpha ? ufrag.GetVertexAlphaCandidates() : null; int vertexCount = positions.Length / 3; var vertices = new Vertex3D[vertexCount]; @@ -81,7 +94,7 @@ private static Vertex3D[] ConvertUFragToVertices(IUFrag ufrag) ? new Vector3(normals[posIdx], normals[posIdx + 1], normals[posIdx + 2]) : Vector3.UnitY; // ZoneReader now supplies real decoded normals/tangents for UFrags (same packed - // 11:11:10 words as VertexFormat0/1 — see UFrag.ReadVertices), so these fallbacks are + // 11:11:10 words as VertexFormat0/1 - see UFrag.ReadVertices), so these fallbacks are // genuine edge-case guards, not the every-vertex default they used to be. The tangent // fallback must stay a real (if arbitrary) unit vector, not Vector4.Zero: // LitModelShaderSource's TBN construction normalizes the tangent, and normalizing a @@ -90,7 +103,7 @@ private static Vertex3D[] ConvertUFragToVertices(IUFrag ufrag) ? new Vector4(tangents[i * 4], tangents[i * 4 + 1], tangents[i * 4 + 2], tangents[i * 4 + 3]) : new Vector4(1f, 0f, 0f, 1f); - // TexCoords2 is the LIGHTMAP UV set (UFragVertex.UVs2), not a copy of the base UV — + // TexCoords2 is the LIGHTMAP UV set (UFragVertex.UVs2), not a copy of the base UV - // the game samples its baked light colour/direction maps there. Falls back to the base // UV when this UFrag has none, which keeps the attribute well-defined for every vertex // rather than leaving it uninitialised; nothing samples it in that case anyway, since @@ -99,44 +112,25 @@ private static Vertex3D[] ConvertUFragToVertices(IUFrag ufrag) ? new Vector2(lightmapUVs[uvIdx], lightmapUVs[uvIdx + 1]) : uv; - vertices[i] = new Vertex3D(position, uv, lightmapUV, normal, tangent, Vector4.One); + float alpha = vertexAlpha != null && i < vertexAlpha.Length ? vertexAlpha[i] : 1f; + vertices[i] = new Vertex3D(position, uv, lightmapUV, normal, tangent, new Vector4(1f, 1f, 1f, alpha)); } return vertices; } - public override void Draw(IRenderer renderer, OutputDescription outputDescription, CommandList commandList, Cam3D camera, ImmediateRenderer immediateRenderer) + protected override void EnsureRenderables() { - if (!allowRender || !EntityManager.Singleton.renderUFrags) return; - - // Culling was dropped earlier this session ("not numerous enough to matter") back when - // BoundingSphere was wrongly zeroed/coincident with the placement translation for new - // engine — now that GetBoundingCenter() is a real, independent bounding sphere again - // (see ZoneReader.ConvertUFrag / the constructor above), reinstate it, same pattern as - // EntityMoby/EntityTie. - var sphere = WorldBoundingSphere; - var sphereCenter = new Vector3(sphere.X, sphere.Y, sphere.Z); - if (EntityManager.Singleton.FrustumCullingEnabled && !camera.GetFrustum().ContainsSphere(sphereCenter, sphere.W)) return; - - if (EntityManager.Singleton.renderBoundingSpheres) - DrawBoundingSphere(outputDescription, commandList, immediateRenderer); - - if (IsDirty) - { - cachedRenderables.Clear(); - cachedRenderables.Add(new Renderable(UFragMesh, Transform)); - IsDirty = false; - } - - foreach (var renderable in cachedRenderables) - renderer.DrawRenderable(renderable); - - EntitiesRenderedThisFrame++; + if (!IsDirty || UFragMesh is null) return; + cachedRenderables.Clear(); + cachedRenderables.Add(new Renderable(UFragMesh, Transform)); + IsDirty = false; } public override void Dispose() { - UFragMesh.Dispose(); + // Nothing to release: the mesh is plain data, and its geometry is owned by the capture + // registry, which the level unload clears wholesale. GC.SuppressFinalize(this); } } diff --git a/ReLunacy.Engine/Scene/EntityVolume.cs b/ReLunacy.Engine/Scene/EntityVolume.cs index 043860b..362d282 100644 --- a/ReLunacy.Engine/Scene/EntityVolume.cs +++ b/ReLunacy.Engine/Scene/EntityVolume.cs @@ -1,15 +1,5 @@ using System.Numerics; -using Bliss.CSharp; -using Bliss.CSharp.Camera.Dim3; -using Bliss.CSharp.Colors; -using Bliss.CSharp.Geometry.Meshes; -using Bliss.CSharp.Graphics.Rendering.Renderers; -using Bliss.CSharp.Graphics.Rendering.Renderers.Forward; -using Bliss.CSharp.Graphics.VertexTypes; -using Bliss.CSharp.Images; -using Bliss.CSharp.Materials; -using Bliss.CSharp.Textures; -using Bliss.CSharp.Transformations; +using ReLunacy.Engine.Rendering.Resources; using ReLunacy.Engine.Assets.LevelElements; using ReLunacy.Engine.Rendering; using Veldrith; @@ -21,120 +11,31 @@ public class EntityVolume : Entity public readonly Volume BaseVolume; public override Vector4 BoundingSphere { get; set; } = Vector4.Zero; - /// The box's real (possibly non-uniform) half-extents source — kept separate from + /// The box's real (possibly non-uniform) half-extents source - kept separate from /// Transform.Scale (always 1,1,1 for a Volume) because each edge instance takes this as an /// explicit length rather than folding it into the placement transform. Only settable via - /// , which keeps BoundingSphere and the edge instances in sync with it — + /// , which keeps BoundingSphere and the edge instances in sync with it - /// never assign this field directly. public Vector3 scale { get; private set; } public override string Name { get; protected set; } - // Both entirely flat-color materials, tinted by the map's own Color — swapped on - // Renderable.Material directly when selection changes, no mesh rebuild needed. Static/shared - // since neither carries any per-instance state; built lazily since GlobalResource isn't - // guaranteed initialized before the first Volume loads otherwise. Colors are live-configurable - // (Editor Settings), so EnsureMaterialsCurrent() rebuilds these in place — and bumps - // _materialsVersion so every EntityVolume's Draw() knows to re-fetch its Renderable's Material - // reference, even one that isn't changing selection state this frame — whenever - // EntityManager's color fields drift from what's currently baked in. - private static Material? _unselectedMaterial; - private static Material? _selectedMaterial; - private static Vector4 _appliedVolumeColor = new(float.NaN); - private static Vector4 _appliedVolumeSelectedColor = new(float.NaN); - private static int _materialsVersion; + // A volume has no mesh and no material of its own. The renderer draws its 12 wireframe edges + // straight from GetWorldEdgeTransforms and VolumeColour, with its own thin-box edge geometry (see + // VulkanRenderer's edge cube, which matches what Primitives.CreateWireEdge used to build) and its + // own flat-colour pipeline. + // + // This used to be a shared unit-length edge mesh plus a pair of tinted materials, rebuilt whenever + // the colour settings or the wire thickness changed. All of it fed a render path that no longer + // exists, and none of it was ever reached by the current one: the edge mesh was never registered + // with the capture registry, so the scene walk skipped these renderables outright. What remains is + // the part that was always doing the work, the 12 edge transforms. - private static Material GetUnselectedMaterial(GraphicsDevice gd) { EnsureMaterialsCurrent(gd); return _unselectedMaterial!; } - private static Material GetSelectedMaterial(GraphicsDevice gd) { EnsureMaterialsCurrent(gd); return _selectedMaterial!; } - - private static void EnsureMaterialsCurrent(GraphicsDevice gd) - { - var color = EntityManager.Singleton.VolumeColor; - var selectedColor = EntityManager.Singleton.VolumeSelectedColor; - if (_unselectedMaterial != null && color == _appliedVolumeColor && selectedColor == _appliedVolumeSelectedColor) - return; - - _unselectedMaterial = BuildTintMaterial(gd, ToColor(color)); - _selectedMaterial = BuildTintMaterial(gd, ToColor(selectedColor)); - _appliedVolumeColor = color; - _appliedVolumeSelectedColor = selectedColor; - _materialsVersion++; - } - - private static Color ToColor(Vector4 v) => new( - (byte)(Math.Clamp(v.X, 0f, 1f) * 255f), - (byte)(Math.Clamp(v.Y, 0f, 1f) * 255f), - (byte)(Math.Clamp(v.Z, 0f, 1f) * 255f), - (byte)(Math.Clamp(v.W, 0f, 1f) * 255f)); - - // GlobalResource.DefaultModelTexture — despite its name/every other comment in this file - // previously assuming it was white — is actually Bliss's own hardcoded 1x1 50% GRAY - // placeholder (confirmed via decompile: GlobalResource's static constructor builds it as - // `new Image(1, 1, Color.Gray)`). The fragment shader does texelColor * maps[0].color, so - // every volume tint was silently getting halved (pure Yellow (1,1,0) * Gray (0.5,0.5,0.5) = - // a dark olive yellow) — nothing to do with lighting or gamma, just multiplying against the - // wrong placeholder texture. Own dedicated solid-WHITE 1x1 texture instead, so the tint color - // is the only thing that reaches the framebuffer. - private static Texture2D? _whiteTexture; - - private static Texture2D GetWhiteTexture(GraphicsDevice gd) => _whiteTexture ??= new Texture2D(gd, new Image(1, 1, Color.White)); - - private static Material BuildTintMaterial(GraphicsDevice gd, Color tint) - { - // Material's own default (RasterizerStateDescription.DEFAULT) back-face culls, and is - // never touched by AssetManager.SetBackfaceCulling (that only tracks materials it built - // itself) — these are edges, not opaque faces, so they should always draw from both sides - // regardless of the app-wide backface-culling setting. - var material = new Material(GlobalResource.DefaultModelEffect, RasterizerStateDescription.CULL_NONE); - material.AddMaterialMap(new MaterialMapKey(MaterialMapType.Albedo), 0, new MaterialMap(GetWhiteTexture(gd), color: tint)); - return material; - } - - // One shared unit-length edge mesh (see Primitives.CreateWireEdge), reused across 12 separate - // Renderables per volume — replaces the old approach of building a whole unique box mesh per - // volume. NOT GPU-instanced: GlobalResource.DefaultModelEffect (used by BuildTintMaterial - // below) is compiled by Bliss itself with no macros at all, so its shader's "#if - // USE_INSTANCING" branch never compiles in and always falls back to a single uTransformation - // uniform — which Renderable sets to Matrix4x4.Identity whenever UseInstancing is true, - // trusting the shader to use per-instance attributes instead. Through this effect, an - // instanced Renderable silently renders every vertex at local-space identity instead of world - // position (confirmed empirically: volumes stopped rendering entirely the one time this was - // tried). 12 non-instanced Renderables sharing one mesh is more draw calls but actually works. - // Only the edge's own thickness lives in this mesh's geometry; each edge's real - // length/position/orientation comes from its own per-instance Transform (see - // RecomputeEdgeTransforms), so rebuilding this mesh (thickness changed) never requires - // recomputing those. - private static Mesh? _sharedEdgeMesh; - private static float _appliedEdgeThickness = float.NaN; - private static int _edgeMeshVersion; - - private static Mesh SharedEdgeMesh => _sharedEdgeMesh!; - - private static void EnsureEdgeMeshCurrent(GraphicsDevice gd) - { - float thickness = EntityManager.Singleton.VolumeWireThickness; - if (_sharedEdgeMesh != null && thickness == _appliedEdgeThickness) - return; - - _sharedEdgeMesh?.Dispose(); - // Material passed here is never actually used for drawing — every Renderable built from - // this mesh uses the explicit-material constructor, which overrides it. Only matters for - // the vertex layout compatibility check Mesh does at construction. - _sharedEdgeMesh = Primitives.CreateWireEdge(gd, GetUnselectedMaterial(gd), thickness); - _appliedEdgeThickness = thickness; - _edgeMeshVersion++; - } - - private readonly GraphicsDevice _gd; private Transform[] _edgeTransforms = []; - private bool _drawnSelected; - private int _appliedMaterialsVersion = -1; - private int _appliedEdgeMeshVersion = -1; - public EntityVolume(Volume volume, GraphicsDevice gd) + public EntityVolume(Volume volume) { BaseVolume = volume; - _gd = gd; Name = !string.IsNullOrEmpty(volume.Name) ? volume.Name : $"Volume_{ID}"; Matrix4x4.Decompose(volume.transform, out var initialScale, out var rotation, out var position); @@ -145,28 +46,58 @@ public EntityVolume(Volume volume, GraphicsDevice gd) } /// Sets and, in the same step, recomputes the local-space bounding - /// sphere and the 12 edge instances — the three always have to change together, so this is the + /// sphere and the 12 edge transforms. The three always have to change together, so this is the /// only way to change the volume's size (from the Property Inspector or otherwise). Rebuilds - /// synchronously rather than waiting for Draw()'s IsDirty check: View3D's picking reads - /// cachedRenderables directly, independent of Draw() (which may not even run this frame if the - /// volume is culled or Render > Volumes is off), so a stale entry could otherwise report the - /// pre-resize size for up to a frame. + /// synchronously rather than leaving it to the IsDirty check, so a resize can never report the + /// pre-resize size for a frame. public void SetScale(Vector3 newScale) { scale = newScale; // BoundingSphere is LOCAL space per Entity's convention (offset from Transform.Translation) - // — center coincides with the volume's own position (zero local offset), and the radius is + // - center coincides with the volume's own position (zero local offset), and the radius is // the distance from that center to the cube's furthest corner: the half-extents vector's // length, since one corner sits at exactly (sx/2, sy/2, sz/2) from center. BoundingSphere = new Vector4(Vector3.Zero, (scale / 2f).Length()); - RebuildEdgeRenderable(); + RecomputeEdgeTransforms(); + IsDirty = false; + } + + /// The 12 world matrices of this volume's wireframe edges. Each places and stretches a + /// unit-length edge (see RecomputeEdgeTransforms / ComposeEdgeTransform). The renderer draws its own + /// thin-box edge geometry at these, so the wireframe matches the pick target exactly (and honours + /// VolumeWireThickness). + public IEnumerable GetWorldEdgeTransforms() + { + // Not an iterator itself: the freshness check has to run when this is CALLED, not when it is + // first enumerated. Resizing rebuilds synchronously (SetScale), but a gizmo move or rotate only + // sets IsDirty, so the rebuild has to happen somewhere the renderer actually reaches. + EnsureRenderables(); + return EnumerateWorldEdgeTransforms(); + } + + private IEnumerable EnumerateWorldEdgeTransforms() + { + // _edgeTransforms are ALREADY world transforms: ComposeEdgeTransform folds this volume's own + // Transform in when it builds them. Composing again here would apply the volume's placement + // twice and offset every box. + foreach (var e in _edgeTransforms) + yield return e.GetMatrix(); + } + + protected override void EnsureRenderables() + { + if (!IsDirty) return; + RecomputeEdgeTransforms(); + IsDirty = false; } + /// Current wireframe colour (RGBA, 0..1): the selected or unselected volume tint. + public Vector4 VolumeColour => selected ? EntityManager.Singleton.VolumeSelectedColor : EntityManager.Singleton.VolumeColor; + /// Recomputes the 12 per-edge local Transforms (position/orientation/length) from the - /// current — each edge is a unit-length instance of - /// stretched along its own local X (see + /// current . Each edge is a unit-length segment stretched along its own local X (see /// ) and placed at one of the box's 4 corners parallel to /// that axis, same layout the old single-mesh CreateWireBox used. private void RecomputeEdgeTransforms() @@ -197,7 +128,7 @@ private void AddAxisEdges(Vector3 axisLength, float lengthExtent, Vector3 axisB, /// (length/orientation/offset) with this volume's own Transform, matching the same /// Matrix4x4.Decompose-based composition already used to derive the volume's own Transform /// from its source data in the constructor. Matrix4x4.Decompose can theoretically fail on a - /// degenerate input (never expected here — this volume's own Transform.Scale is always + /// degenerate input (never expected here - this volume's own Transform.Scale is always /// Vector3.One, so there's no shear/reflection to trip it up), in which case the edge falls /// back to this volume's own placement with a zero local offset rather than leaving it at a /// stale or default Transform. @@ -207,7 +138,7 @@ private Transform ComposeEdgeTransform(Vector3 lengthAxis, float length, Vector3 { // Scale is applied in local mesh space BEFORE rotation (see Transform.GetMatrix()'s // Scale*Rotation*Translation order), so Scale.X always stretches SharedEdgeMesh's own - // local length axis regardless of the rotation below — this is what keeps the + // local length axis regardless of the rotation below - this is what keeps the // thickness axes (Y/Z, left at 1) constant no matter how long the edge is. Scale = new Vector3(length, 1f, 1f), Rotation = AlignUnitXTo(lengthAxis), @@ -223,8 +154,8 @@ private Transform ComposeEdgeTransform(Vector3 lengthAxis, float length, Vector3 /// Rotation aligning SharedEdgeMesh's local +X (its length axis) to point along /// (always UnitX/UnitY/UnitZ). Only the axis LINE matters, not its - /// polarity — the edge mesh is symmetric about its own center and radially symmetric in - /// cross-section, so a +90°/-90° sign mismatch here would still produce an identical result. + /// polarity - the edge mesh is symmetric about its own center and radially symmetric in + /// cross-section, so a +90 deg/-90 deg sign mismatch here would still produce an identical result. private static Quaternion AlignUnitXTo(Vector3 axis) { if (axis == Vector3.UnitY) return Quaternion.CreateFromAxisAngle(Vector3.UnitZ, MathF.PI / 2f); @@ -232,95 +163,6 @@ private static Quaternion AlignUnitXTo(Vector3 axis) return Quaternion.Identity; } - /// Rebuilds both the edge transforms and the 12 per-edge Renderables from scratch — - /// needed whenever the volume's own size changes. NOT GPU-instanced (see the class-level - /// comment on for why) — 12 small Renderables all pointing at - /// the one shared mesh, which is still cheap to reconstruct (no unique per-volume mesh data - /// involved, unlike the old approach). - private void RebuildEdgeRenderable() - { - EnsureEdgeMeshCurrent(_gd); - RecomputeEdgeTransforms(); - - cachedRenderables.Clear(); - var material = selected ? GetSelectedMaterial(_gd) : GetUnselectedMaterial(_gd); - foreach (var edgeTransform in _edgeTransforms) - cachedRenderables.Add(new Renderable(SharedEdgeMesh, edgeTransform, material)); - IsDirty = false; - _drawnSelected = selected; - _appliedMaterialsVersion = _materialsVersion; - _appliedEdgeMeshVersion = _edgeMeshVersion; - } - - public override void Draw(IRenderer renderer, OutputDescription outputDescription, CommandList commandList, Cam3D camera, ImmediateRenderer immediateRenderer) - { - if (!allowRender || !EntityManager.Singleton.renderVolumes) return; - - // Was ContainsOrientedBox(boundingBox, Transform.Translation, Transform.Rotation) — the - // only entity type culling against an OBB instead of its BoundingSphere, and it was - // dropping volumes that were still visibly inside the frustum. Switched to the same - // ContainsSphere/WorldBoundingSphere check every other entity (Moby/Tie/UFrag) uses; the - // sphere fully encloses the box (see the constructor's radius derivation), so this can't - // cull anything the OBB test would have kept. - var sphere = WorldBoundingSphere; - var sphereCenter = new Vector3(sphere.X, sphere.Y, sphere.Z); - if (EntityManager.Singleton.FrustumCullingEnabled && !camera.GetFrustum().ContainsSphere(sphereCenter, sphere.W)) return; - - if (EntityManager.Singleton.renderBoundingSpheres) - DrawBoundingSphere(outputDescription, commandList, immediateRenderer); - - // Must run before reading the version fields below: these are what actually rebuild the - // shared static materials/edge mesh in place if their configured values changed, and bump - // the corresponding version counter. Called unconditionally (not just from the getters) so - // a volume whose selection state isn't changing this frame still notices a color/thickness - // change on the very frame it happens, rather than only whenever some other volume's - // Draw() happens to touch a getter first. - EnsureMaterialsCurrent(_gd); - EnsureEdgeMeshCurrent(_gd); - bool materialsChanged = _appliedMaterialsVersion != _materialsVersion; - bool edgeMeshChanged = _appliedEdgeMeshVersion != _edgeMeshVersion; - - if (IsDirty) - { - // Volume moved/rotated (Transform's setter sets IsDirty) — the 12 edge transforms are - // derived from Transform, so they need recomputing, not just a material/mesh swap. - RebuildEdgeRenderable(); - } - else if (edgeMeshChanged) - { - // Only the shared mesh reference changed (thickness setting) — Renderable.Mesh has no - // setter, so new Renderables are still needed, but the existing edge transforms are - // still correct (thickness never factors into them) and don't need recomputing. - cachedRenderables.Clear(); - var material = selected ? GetSelectedMaterial(_gd) : GetUnselectedMaterial(_gd); - foreach (var edgeTransform in _edgeTransforms) - cachedRenderables.Add(new Renderable(SharedEdgeMesh, edgeTransform, material)); - _drawnSelected = selected; - _appliedMaterialsVersion = _materialsVersion; - _appliedEdgeMeshVersion = _edgeMeshVersion; - } - else if (_drawnSelected != selected || materialsChanged) - { - // Selection (or the shared tint materials themselves) changed without a geometry - // rebuild — swap the material reference directly on all 12 (Renderable.Material has a - // public setter that flags its own GPU buffer dirty), no need to touch the mesh or - // cachedRenderables list itself. View3D skips its generic selection-outline pass for - // Volumes entirely (see View3D.Render) in favor of this — the outline technique - // inflates along vertex normals and expects one closed mesh, which the 12 disjoint - // edge instances are not. - var material = selected ? GetSelectedMaterial(_gd) : GetUnselectedMaterial(_gd); - foreach (var renderable in cachedRenderables) - renderable.Material = material; - _drawnSelected = selected; - _appliedMaterialsVersion = _materialsVersion; - } - - foreach (var renderable in cachedRenderables) - renderer.DrawRenderable(renderable); - - EntitiesRenderedThisFrame++; - } - public override void Dispose() { GC.SuppressFinalize(this); diff --git a/ReLunacy.Engine/Scene/EntityZone.cs b/ReLunacy.Engine/Scene/EntityZone.cs index af39544..0383059 100644 --- a/ReLunacy.Engine/Scene/EntityZone.cs +++ b/ReLunacy.Engine/Scene/EntityZone.cs @@ -1,6 +1,3 @@ -using Bliss.CSharp.Camera.Dim3; -using Bliss.CSharp.Graphics.Rendering.Renderers; -using Bliss.CSharp.Graphics.Rendering.Renderers.Forward; using ReLunacy.Engine.Assets.Interfaces; using ReLunacy.Engine.Rendering; using Veldrith; @@ -34,17 +31,6 @@ public EntityZone(IZone zone, GraphicsDevice gd, AssetManager assetManager) UFrags.Add(ufrag, gd); } - public void Draw(IRenderer renderer, OutputDescription od, CommandList cl, Cam3D camera, ImmediateRenderer immediateRenderer) - { - if (!allowRender) return; - - if (EntityManager.Singleton.renderTies) - TieInstances.Draw(renderer, od, cl, camera, immediateRenderer); - - if (EntityManager.Singleton.renderUFrags) - UFrags.Draw(renderer, od, cl, camera, immediateRenderer); - } - public void Dispose() { TieInstances.Dispose(); diff --git a/ReLunacy.Engine/Shaders/billboardf.glsl b/ReLunacy.Engine/Shaders/billboardf.glsl new file mode 100644 index 0000000..6e73b30 --- /dev/null +++ b/ReLunacy.Engine/Shaders/billboardf.glsl @@ -0,0 +1,41 @@ +#version 450 + +#define MAX_MAPS_COUNT 8 + +struct MaterialMap { + vec4 color; + float value; +}; + +layout(std140, set = 2, binding = 0) uniform MaterialBuffer { + int renderMode; + MaterialMap maps[MAX_MAPS_COUNT]; +}; + +layout (set = 3, binding = 0) uniform texture2D fAlbedo; +layout (set = 3, binding = 1) uniform sampler fAlbedoSampler; + +layout (location = 0) in vec2 fTexCoords; +layout (location = 1) in vec4 fColor; + +layout (location = 0) out vec4 fFragColor; + +void main() { + vec4 texelColor = texture(sampler2D(fAlbedo, fAlbedoSampler), fTexCoords); + + switch (renderMode) { + case 0: + texelColor.a = 1.0F; + break; + case 1: + // Same clip rule as the other effects: the material's own threshold, compared + // with <= so a threshold of 0 (old engine, which clips at zero) still discards + // fully transparent texels. See MaterialReader.GetAlphaClip. + if (texelColor.a <= maps[0].value) { + discard; + } + break; + } + + fFragColor = texelColor * maps[0].color * fColor; +} diff --git a/ReLunacy.Engine/Shaders/billboardv.glsl b/ReLunacy.Engine/Shaders/billboardv.glsl new file mode 100644 index 0000000..d0f64e2 --- /dev/null +++ b/ReLunacy.Engine/Shaders/billboardv.glsl @@ -0,0 +1,41 @@ +#version 450 + +layout(std140, set = 0, binding = 0) uniform MatrixBuffer { + mat4x4 uProjection; + mat4x4 uView; +}; + +layout(std140, set = 1, binding = 0) uniform TransformBuffer { + mat4x4 uTransformation; +}; + +layout (location = 0) in vec3 vPosition; +layout (location = 1) in vec2 vTexCoords; +layout (location = 2) in vec2 vTexCoords2; +layout (location = 3) in vec3 vNormal; +layout (location = 4) in vec4 vTangent; +layout (location = 5) in vec4 vColor; + +layout (location = 0) out vec2 fTexCoords; +layout (location = 1) out vec4 fColor; + +void main() { + fTexCoords = vTexCoords; + fColor = vColor; + + // Anchor into view space, then offset along the view axes so the quad always faces + // the camera. vTexCoords2 is the card-local corner offset. + vec4 anchorView = uView * uTransformation * vec4(vPosition, 1.0F); + + // The offset is added AFTER the model matrix, so it would otherwise miss the + // instance's scale entirely and every card would render at asset-local size. Recover + // that scale from the model matrix's own basis vectors - column 0 and column 1 are the + // X and Y axes, and their lengths are the scale on each. Foliage placements are + // uniformly scaled in practice (measured over all 757 on metropolis: X, Y and Z basis + // lengths agree to 0.0000 relative), so taking them per-axis costs nothing and stays + // correct if a level ever scales non-uniformly. + vec2 instanceScale = vec2(length(uTransformation[0].xyz), length(uTransformation[1].xyz)); + anchorView.xy += vTexCoords2 * instanceScale; + + gl_Position = uProjection * anchorView; +} diff --git a/ReLunacy.Engine/Shaders/litmodelf.glsl b/ReLunacy.Engine/Shaders/litmodelf.glsl new file mode 100644 index 0000000..ccf6713 --- /dev/null +++ b/ReLunacy.Engine/Shaders/litmodelf.glsl @@ -0,0 +1,255 @@ +#version 450 + +// Lit fragment shader, ported from the game's own captured fragment program (RPCS3 + RenderDoc, +// traced in fragment_shader_annotated.glsl). Shading model follows Insomniac's "Prelighting" decks, +// evaluated forward instead of deferred: this editor has one light rig and no dynamic lights. +// +// TEXTURE CHANNEL MAP (all confirmed from the capture, all pure intensities, never tints): +// Properties ("expensive") R = specular G = parallax height B = emissive A = detail mask +// Detail R,G = normal derivative delta B = albedo lift (unused) A = spec add +// Normal stores PARTIAL DERIVATIVES, not a normal: dx = A, dy = G, +// reconstructed as normalize(vec3(dx, dy, 1)) with no sign flip. +// Channel order confirmed against Negotiator/TextureEditor's DXT5 path. +// +// LIGHTING SOURCES, in priority order: +// 1. Baked lightmaps (zone sections 0x5400 colour / 0x5410 tangent-space direction), per INSTANCE, +// sampled at the SECOND UV set. Where a bake exists it REPLACES the analytic lights. +// 2. The level's analytic lighting environment (main.dat 0x8b00): ambient + two directional lights. +// 3. Flat editor ambient, only when the level supplies neither. +// The cubemap reflection (0x5920) is additive on top and is the ONLY specular the game applies. +// +// All lighting is in LINEAR space: albedo is sRGB-decoded on read and the result re-encoded at the +// end. Light/bake values are NOT decoded, they are intensities, not authored colour. + +#define MAX_MAPS_COUNT 8 + +struct MaterialMap { + vec4 color; + float value; +}; + +// Set numbering is dictated by Bliss's pipeline layout: every uniform buffer first in a contiguous +// run from 0, then every texture. Interleaving them faults the GPU. See AssetManager.BuildLitModelEffect. +layout(std140, set = 2, binding = 0) uniform MaterialBuffer { + int renderMode; + MaterialMap maps[MAX_MAPS_COUNT]; +}; + +layout(std140, set = 3, binding = 0) uniform LightBuffer { + vec3 uLightDirection; + float uAmbient; + vec3 uLightColor; + float uSpecularPower; + vec3 uCameraPosition; + float uReflectionDebug; + vec3 uEnvironmentColour; + float uEnvironmentIntensity; + // Lightmap UV transform + bake tuning: research controls, identity/neutral by default. + vec2 uLightmapUVScale; + vec2 uLightmapUVOffset; + float uBakedLightScale; + float uBakedBumpFade; + float uBakedDebugView; + float uReflectionBase; + vec2 uLightmapUVPivot; + float uLightmapUVRotation; + float _reserved2; + // The level's analytic lighting environment (0x8b00). uEnvHasLighting gates it. + vec3 uEnvDirection0; + float uEnvHasLighting; + vec3 uEnvDirection1; + float _padding4; + vec3 uEnvAmbient; + float _padding5; + vec3 uEnvLight0Colour; + float _padding6; + vec3 uEnvLight1Colour; + float _padding7; +}; + +layout (set = 4, binding = 0) uniform texture2D fAlbedo; +layout (set = 4, binding = 1) uniform sampler fAlbedoSampler; + +layout (set = 5, binding = 0) uniform texture2D fNormal; +layout (set = 5, binding = 1) uniform sampler fNormalSampler; + +layout (set = 6, binding = 0) uniform texture2D fProperties; +layout (set = 6, binding = 1) uniform sampler fPropertiesSampler; + +layout (set = 7, binding = 0) uniform texture2D fDetail; +layout (set = 7, binding = 1) uniform sampler fDetailSampler; + +// Baked lighting, per instance. Always bound (inert fallbacks when absent) because an unbound +// declared set is undefined behaviour; maps[6].value is what says a real bake exists. +layout (set = 8, binding = 0) uniform texture2D fLightColour; +layout (set = 8, binding = 1) uniform sampler fLightColourSampler; + +layout (set = 9, binding = 0) uniform texture2D fLightDir; +layout (set = 9, binding = 1) uniform sampler fLightDirSampler; + +// Environment cubemap, faces in the file's own +X,-X,+Y,-Y,+Z,-Z order. Sampled ONLY for reflections. +layout (set = 10, binding = 0) uniform textureCube fEnvCube; +layout (set = 10, binding = 1) uniform sampler fEnvCubeSampler; + +layout (location = 0) in vec2 fTexCoords; +layout (location = 1) in vec4 fColor; +layout (location = 2) in vec3 fWorldNormal; +layout (location = 3) in vec3 fWorldTangent; +layout (location = 4) in float fTangentHandedness; +layout (location = 5) in vec3 fWorldPos; +// The LIGHTMAP UV set (vertex attribute 2), settled by the captured vertex program. +layout (location = 6) in vec2 fTexCoords2; + +layout (location = 0) out vec4 fFragColor; + +void main() { + vec3 n = normalize(fWorldNormal); + vec3 t = normalize(fWorldTangent - n * dot(fWorldTangent, n)); + vec3 b = cross(n, t) * fTangentHandedness; + mat3 tbn = mat3(t, b, n); + + vec3 viewDir = normalize(uCameraPosition - fWorldPos); + + // PARALLAX, ported exactly: height = raw * scale + bias, offset ADDED, and NOT divided by + // viewDirTS.z. The classic divide blows the UV shift up at grazing angles and, with a single tap, + // shreds the sampling into swimming artifacts. A negative scale simply flips the relief direction, + // so which way it moves is DATA, not a bug. Height is sampled at the UN-offset UV. + vec3 viewDirTS = transpose(tbn) * viewDir; + float heightRaw = texture(sampler2D(fProperties, fPropertiesSampler), fTexCoords).g; + float height = heightRaw * maps[3].value + maps[4].value; + vec2 texCoords = fTexCoords + viewDirTS.xy * height; + + vec4 texelColor = texture(sampler2D(fAlbedo, fAlbedoSampler), texCoords); + + // maps[0].value is the alpha-clip threshold. The comparison is <=, not <: on the old engine the + // threshold is 0, and a strict < would never discard anything. + switch (renderMode) { + case 0: + texelColor.a = 1.0F; + break; + case 1: + if (texelColor.a <= maps[0].value) { + discard; + } + break; + } + + // Normal map: partial derivatives (dx = A, dy = G), see the header. + vec4 normalSample = texture(sampler2D(fNormal, fNormalSampler), texCoords); + float dx = normalSample.a * 2.0F - 1.0F; + float dy = normalSample.g * 2.0F - 1.0F; + + vec4 propsSample = texture(sampler2D(fProperties, fPropertiesSampler), texCoords); + float specIntensity = propsSample.r; + float emissiveIntensity = propsSample.b; + // Detail mask is the properties alpha, but only where that texture HAS an alpha channel. + // maps[1].value flags which case this is; block formats without alpha decode to an unauthored + // 1.0, which is what the original hardware produced too, so it simply does not attenuate. + float detailMask = mix(1.0F, propsSample.a, maps[1].value); + + // DETAIL MAP. maps[5].value is just "this material uses its detail map" (1/0). The per-channel + // strengths this once multiplied by are gone: they read ShaderMetadataOld 0x28/0x2C/0x30, which + // the EBOOT reverse proves is an unrelated RGB parameter triple (dev/chatgpt-eboot-4,5.txt). + // The game does scale these by constants of its own, but none is located, so the derivatives are + // added plainly, gated only by the mask. Plain ADDITION is the whole point of the encoding: two + // derivative maps compose with no reorientation. Decode to signed BEFORE masking, or a fully + // masked-out texel becomes a full-strength -1 perturbation. + // maps[2].value is the detail UV tiling (ShaderMetadataOld 0x58). Detail samples at the un-offset + // base UV: the game gives it its own scaled share of the parallax shift, which is not identified. + vec2 detailCoords = fTexCoords * maps[2].value; + vec4 detailSample = texture(sampler2D(fDetail, fDetailSampler), detailCoords); + vec2 detailDerivative = vec2(detailSample.r * 2.0F - 1.0F, detailSample.g * 2.0F - 1.0F) * (detailMask * maps[5].value); + float detailSpecAdd = detailSample.a * detailMask * maps[5].value; + + vec2 derivativeSum = vec2(dx, dy) + detailDerivative; + vec3 tangentNormal = normalize(vec3(derivativeSum, 1.0F)); + vec3 worldNormal = normalize(tbn * tangentNormal); + + specIntensity = specIntensity + detailSpecAdd; + + vec3 albedo = pow(texelColor.rgb * maps[0].color.rgb * fColor.rgb, vec3(2.2F)); + + vec3 reflDir = reflect(-viewDir, worldNormal); + + // BAKED LIGHTING. maps[6].value is 1 only where a real bake exists. The UV transform above it is + // a research control (identity by default): rotation about a pivot, then scale and offset. The + // pivot is adjustable because UVs2 are ATLAS coordinates, so rotating about the atlas centre + // would sweep an island across unrelated ones instead of spinning it in place. + float hasBaked = maps[6].value; + vec2 uvCentred = fTexCoords2 - uLightmapUVPivot; + float uvSin = sin(radians(uLightmapUVRotation)); + float uvCos = cos(radians(uLightmapUVRotation)); + vec2 uvRotated = vec2(uvCentred.x * uvCos - uvCentred.y * uvSin, + uvCentred.x * uvSin + uvCentred.y * uvCos) + uLightmapUVPivot; + vec2 bakedUV = uvRotated * uLightmapUVScale + uLightmapUVOffset; + vec4 bakedColour = texture(sampler2D(fLightColour, fLightColourSampler), bakedUV); + vec4 bakedDirSample = texture(sampler2D(fLightDir, fLightDirSampler), bakedUV); + + // Tangent-space light direction. Channel order is (r, b, g), NOT (r, g, b): the normal-ward + // component lives in GREEN. Measured over 400 directional maps, G never drops below ~126 while + // R and B centre on 128 with symmetric spread, and DXT1 gives green the extra bit of precision. + // Components are taken RAW with NO signed expansion: as stored, the mean vector length is 0.955, + // whereas every signed-expansion variant lands at 0.28 to 0.64. A unit field is what a direction is. + vec3 bakedLightDirTS = vec3(bakedDirSample.r, bakedDirSample.b, bakedDirSample.g); + float bakedLen = length(bakedLightDirTS); + bakedLightDirTS = bakedLen > 0.0F ? bakedLightDirTS / bakedLen : vec3(0.0F, 0.0F, 1.0F); + + // N.L stays in tangent space, matching the capture. uBakedBumpFade stands in for the game's + // per-vertex distance bump fade (vViewTS.w), which flattens normals with distance. At full + // strength the derivatives can drive the dot negative and punch black holes through correct bake. + vec3 bakedNormalTS = normalize(vec3(derivativeSum * uBakedBumpFade, 1.0F)); + float bakedNdotL = clamp(dot(bakedLightDirTS, bakedNormalTS), 0.0F, 1.0F); + // Dividing by the light's own .z is the signature of directional lightmaps: it makes a FLAT + // normal reproduce the baked intensity exactly, so the normal map modulates the bake rather than + // cancelling it. uBakedLightScale stands in for the game's per-draw lightScale (vc[2].z): the + // bake is genuinely dark as stored (non-black texels average 32/255). + float bakedDiffuse = bakedLightDirTS.z > 0.0F ? bakedNdotL / bakedLightDirTS.z : bakedNdotL; + vec3 bakedDiffuseLight = bakedColour.rgb * bakedDiffuse * uBakedLightScale; + + // Surfaces with no bake use the level's analytic rig, or the flat editor ambient if it has none. + // Not the whole story: the game also modulates these by a per-vertex baked term (tc1.x) that + // needs a vertex-constants capture to decode. + vec3 envDiffuse = uEnvAmbient + + uEnvLight0Colour * max(dot(worldNormal, uEnvDirection0), 0.0F) + + uEnvLight1Colour * max(dot(worldNormal, uEnvDirection1), 0.0F); + vec3 undecodedFill = mix(vec3(uAmbient), envDiffuse, uEnvHasLighting); + // Emissive sits INSIDE the light term so it multiplies by albedo: a surface glows in its own colour. + vec3 lighting = mix(undecodedFill, bakedDiffuseLight, hasBaked) + emissiveIntensity; + + // ENVIRONMENT FILL, the only specular the game applies. Additive and NOT gated by the lightmap, + // which is what stops baked shadows reaching pure black. TINTED BY ALBEDO: the capture computes + // a specular tint from albedo, and an untinted grey fill desaturates the whole scene. + // The cube's RGB is a mantissa and its alpha an HDR exponent. Sampled at mip 0 (no mip chain), so + // the reflection is sharp, which is what the game shows. Reflectivity is the material's specular + // map plus a Schlick-Fresnel base, so flat surfaces reflect too; uReflectionBase is F0. + // The game modulates specular by the bake's ALPHA (monochrome specular light); DXT1 bakes have no + // authored alpha and decode to 1.0, correctly leaving it unattenuated. + float bakedSpecLight = mix(1.0F, bakedColour.a, hasBaked); + vec3 specularTint = albedo; + vec4 envTexel = texture(samplerCube(fEnvCube, fEnvCubeSampler), reflDir); + float envExposure = exp2((envTexel.a * 255.0F - 128.0F) / 16.0F); + vec3 envColour = envTexel.rgb * envExposure; + float NdotV = clamp(dot(worldNormal, viewDir), 0.0F, 1.0F); + float fresnel = uReflectionBase + (1.0F - uReflectionBase) * pow(1.0F - NdotV, 5.0F); + float reflectivity = clamp(specIntensity + fresnel, 0.0F, 1.0F); + vec3 envFill = envColour * specularTint * uEnvironmentIntensity * reflectivity * bakedSpecLight; + // No Phong lobe: it modelled a directional light this game does not have. (uLightDirection, + // uLightColor and uSpecularPower are therefore unused here, kept only for buffer layout.) + vec3 specPart = envFill; + + vec3 finalColor = albedo * lighting + specPart; + + // Debug: the raw bake with no albedo or shading, so a black surface is immediately either + // "the bake is black here" or "shading is killing it". + if (uBakedDebugView > 0.5F) { + finalColor = mix(vec3(0.25F), bakedColour.rgb * uBakedLightScale, hasBaked); + } + + // Debug: the cubemap reflection on every surface, ungated. Doubles as an axis-orientation check. + if (uReflectionDebug > 0.5F) { + finalColor = envColour; + } + + finalColor = pow(finalColor, vec3(1.0F / 2.2F)); + fFragColor = vec4(finalColor, texelColor.a * maps[0].color.a * fColor.a); +} diff --git a/ReLunacy.Engine/Shaders/litmodelv.glsl b/ReLunacy.Engine/Shaders/litmodelv.glsl new file mode 100644 index 0000000..aac5f2d --- /dev/null +++ b/ReLunacy.Engine/Shaders/litmodelv.glsl @@ -0,0 +1,70 @@ +#version 450 + +layout(std140, set = 0, binding = 0) uniform MatrixBuffer { + mat4x4 uProjection; + mat4x4 uView; +}; + +// Per-object world transforms for the whole batch, indexed by gl_InstanceIndex. Replaces the old +// per-draw TransformBuffer uniform: the renderer uploads every visible instance's matrix ONCE into +// this storage buffer and issues instanced draws, so there is no per-draw descriptor bind. The +// matrix bytes are System.Numerics.Matrix4x4 uploaded as-is (no transpose), same convention the old +// uniform used, so the maths below is unchanged. Named InstanceTransforms so the renderer detects an +// instanced effect by this buffer's presence. +layout(std430, set = 1, binding = 0) readonly buffer InstanceTransforms { + mat4x4 uTransforms[]; +}; + +layout (location = 0) in vec3 vPosition; +layout (location = 1) in vec2 vTexCoords; +layout (location = 2) in vec2 vTexCoords2; +layout (location = 3) in vec3 vNormal; +layout (location = 4) in vec4 vTangent; +layout (location = 5) in vec4 vColor; + +layout (location = 0) out vec2 fTexCoords; +layout (location = 1) out vec4 fColor; +layout (location = 2) out vec3 fWorldNormal; +layout (location = 3) out vec3 fWorldTangent; +layout (location = 4) out float fTangentHandedness; +layout (location = 5) out vec3 fWorldPos; +// The LIGHTMAP UV set. Proven by the captured vertex program: it writes +// tc0 = (attr1.xy, attr2.xy), and the fragment program samples the baked light colour and +// direction at tc0.zw - i.e. vertex attribute 2, a second UV pair, NOT the base UV. +layout (location = 6) out vec2 fTexCoords2; + +void main() { + // This draw's slice of the batch: gl_InstanceIndex = firstInstance (set per batch by the + // renderer) + the instance within the draw. + mat4x4 uTransformation = uTransforms[gl_InstanceIndex]; + + fTexCoords = vTexCoords; + fTexCoords2 = vTexCoords2; + fColor = vColor; + + // A proper inverse-transpose normal matrix, not just the upper 3x3 of the world + // transform - the naive matrix only happens to give the right answer for the special + // case of a pure +-1-magnitude axis flip with no real scaling; this engine's actual + // per-asset/per-instance Scale is an arbitrary float (moby.Scale, instance placement + // scale, etc.), and for any OTHER scale magnitude - including negative ones used to + // bake in a mirrored placement, which this game does often instead of an actual + // rotation - the naive transform distorts the normal instead of just mirroring it, + // which is what was reading as "shading looks inverted" on those instances. + mat3 modelMatrix3 = mat3(uTransformation); + mat3 normalMatrix = transpose(inverse(modelMatrix3)); + fWorldNormal = normalize(normalMatrix * vNormal); + fWorldTangent = normalize(normalMatrix * vTangent.xyz); + + // Separately from the normal matrix above: a mirrored (negative-determinant) instance + // transform also flips the surface's effective winding, so the TANGENT-SPACE + // reconstruction in the fragment shader needs its bitangent handedness flipped to + // match, or per-pixel normal-map detail comes out inverted even once the plain + // per-vertex normal above is correct. + fTangentHandedness = vTangent.w * sign(determinant(modelMatrix3)); + + mat4x4 transformation = uTransformation; + vec4 v4Pos = vec4(vPosition, 1.0F); + vec4 worldPos = transformation * v4Pos; + fWorldPos = worldPos.xyz; + gl_Position = uProjection * uView * worldPos; +} diff --git a/ReLunacy.Engine/Shaders/vertexalphaf.glsl b/ReLunacy.Engine/Shaders/vertexalphaf.glsl new file mode 100644 index 0000000..fff3fb4 --- /dev/null +++ b/ReLunacy.Engine/Shaders/vertexalphaf.glsl @@ -0,0 +1,42 @@ +#version 450 + +#define MAX_MAPS_COUNT 8 + +struct MaterialMap { + vec4 color; + float value; +}; + +layout(std140, set = 2, binding = 0) uniform MaterialBuffer { + int renderMode; + MaterialMap maps[MAX_MAPS_COUNT]; +}; + +layout (set = 3, binding = 0) uniform texture2D fAlbedo; +layout (set = 3, binding = 1) uniform sampler fAlbedoSampler; + +layout (location = 0) in vec2 fTexCoords; +layout (location = 1) in vec4 fColor; + +layout (location = 0) out vec4 fFragColor; + +void main() { + vec4 texelColor = texture(sampler2D(fAlbedo, fAlbedoSampler), fTexCoords); + + switch (renderMode) { + case 0: + texelColor.a = 1.0F; + break; + case 1: + // Same clip rule as LitModelShaderSource - read the material's own threshold + // instead of the 0.99 constant that used to be here, and compare with <= so a + // threshold of 0 (old engine, which clips at zero) still discards fully + // transparent texels. See MaterialReader.GetAlphaClip. + if (texelColor.a <= maps[0].value) { + discard; + } + break; + } + + fFragColor = texelColor * maps[0].color * fColor; +} diff --git a/ReLunacy.Engine/Shaders/vertexalphav.glsl b/ReLunacy.Engine/Shaders/vertexalphav.glsl new file mode 100644 index 0000000..8557415 --- /dev/null +++ b/ReLunacy.Engine/Shaders/vertexalphav.glsl @@ -0,0 +1,29 @@ +#version 450 + +layout(std140, set = 0, binding = 0) uniform MatrixBuffer { + mat4x4 uProjection; + mat4x4 uView; +}; + +layout(std140, set = 1, binding = 0) uniform TransformBuffer { + mat4x4 uTransformation; +}; + +layout (location = 0) in vec3 vPosition; +layout (location = 1) in vec2 vTexCoords; +layout (location = 2) in vec2 vTexCoords2; +layout (location = 3) in vec3 vNormal; +layout (location = 4) in vec4 vTangent; +layout (location = 5) in vec4 vColor; + +layout (location = 0) out vec2 fTexCoords; +layout (location = 1) out vec4 fColor; + +void main() { + fTexCoords = vTexCoords; + fColor = vColor; + + mat4x4 transformation = uTransformation; + vec4 v4Pos = vec4(vPosition, 1.0F); + gl_Position = uProjection * uView * transformation * v4Pos; +} diff --git a/ReLunacy/Core/Frames/DockedFrames/AssetViewer.cs b/ReLunacy/Core/Frames/DockedFrames/AssetViewer.cs index e493854..d73018d 100644 --- a/ReLunacy/Core/Frames/DockedFrames/AssetViewer.cs +++ b/ReLunacy/Core/Frames/DockedFrames/AssetViewer.cs @@ -1,16 +1,5 @@ using System.Numerics; -using Rectangle = System.Drawing.Rectangle; -using Point = System.Drawing.Point; -using Bliss.CSharp.Camera.Dim3; -using Bliss.CSharp.Geometry.Meshes; -using Bliss.CSharp.Geometry.Models; -using Bliss.CSharp.Graphics.Rendering.Renderers; -using Bliss.CSharp.Graphics.Rendering.Renderers.Forward; -using Bliss.CSharp.Interact; -using Bliss.CSharp.Interact.Mice; -using Bliss.CSharp.Materials; -using Bliss.CSharp.Textures; -using Bliss.CSharp.Transformations; +using ReLunacy.Engine.Rendering.Resources; using ReLunacy.Core.Frames.Modals; using ReLunacy.Core.Selection; using ReLunacy.Engine.Assets.Interfaces; @@ -28,7 +17,7 @@ namespace ReLunacy.Core.Frames.DockedFrames; public record struct MobyAsset { - public MobyAsset(Model[] mobyModel, Moby moby) + public MobyAsset(RenderModel[] mobyModel, Moby moby) { Moby = moby; Model = mobyModel; @@ -38,11 +27,11 @@ public MobyAsset(Model[] mobyModel, Moby moby) { RenderModelMap[i] = true; foreach (var mesh in Model[i].Meshes) - verticesCount += mesh.VertexCount; + verticesCount += (uint)mesh.VertexCount; } } - public Model[] Model; + public RenderModel[] Model; public bool[] RenderModelMap; public Moby Moby; public string MobyName; @@ -51,30 +40,30 @@ public MobyAsset(Model[] mobyModel, Moby moby) public record struct TieAsset { - public TieAsset(Model tieModel, Tie tie) + public TieAsset(RenderModel tieModel, Tie tie) { Tie = tie; Model = tieModel; TieName = tie.Name ?? tie.Id.ToString("X"); foreach (var mesh in Model.Meshes) - verticesCount += mesh.VertexCount; + verticesCount += (uint)mesh.VertexCount; } - public Model Model; + public RenderModel Model; public Tie Tie; public string TieName; public uint verticesCount; } /// Terrain fragment, listed alongside Mobys and Ties even though it is not an "asset" in -/// the same sense — UFrags are not instanced, so each one IS its own single placement. +/// the same sense - UFrags are not instanced, so each one IS its own single placement. /// /// That is exactly why they belong here: a UFrag's bake is unambiguous. A Tie's lightmap depends on /// which instance you are looking at (one Tie asset, many placements, a different bake index each), /// so there is no context-free answer to "what does this asset's lightmap look like"; for a UFrag /// there is. It is currently the only asset type whose baked lighting can be inspected on its own. /// -/// Holds no Bliss Model of its own: the preview reuses the scene EntityUFrag's already-built mesh +/// Holds no Model of its own: the preview reuses the scene EntityUFrag's already-built mesh /// (see ResolveUFragMesh), so what is previewed is byte-identical to what the 3D view draws, /// lightmap material and all, with no second copy to keep in sync or dispose. public record struct UFragAsset @@ -112,7 +101,7 @@ public UFragAsset(ulong zoneId, int index, IUFrag ufrag) public string UFragName; public uint verticesCount; public uint triangleCount; - /// Geometric centre and radius in RAW fixed-point x256 units — divide by 256 for world units. + /// Geometric centre and radius in RAW fixed-point x256 units - divide by 256 for world units. public Vector3 localCentre; public float localRadius; @@ -125,31 +114,32 @@ public class AssetViewer : DockedFrame, ILevelListener protected override Vector2 DefaultPosition { get; set; } = ImGui.GetWorkCenter(ImGui.GetMainViewport()); protected override ImGuiWindowFlags WindowFlags { get; set; } = ImGuiWindowFlags.NoScrollbar; - public Rectangle RenderFrameSize { get; private set; } - public Vector2 RenderFramePos { get; private set; } - public Vector2 MousePos { get; private set; } - public MouseGrabHandler rmbghandler = new() { mouseButton = Bliss.CSharp.Interact.Mice.MouseButton.Right }; - public MouseGrabHandler mmbghandler = new() { mouseButton = Bliss.CSharp.Interact.Mice.MouseButton.Middle }; + // The preview image, its toolbar, and the rules for which of them gets a click. Same component the + // level view uses, so the two cannot drift apart on where the mouse is or who gets it. + private readonly Viewport3D _viewport = new(); + private readonly MouseGrabHandler rmbghandler = new() { mouseButton = MouseButton.Right }; + private readonly MouseGrabHandler mmbghandler = new() { mouseButton = MouseButton.Middle }; private readonly GraphicsDevice graphicsDevice; - private RenderTexture2D renderTexture; - private readonly IRenderer renderer; - private readonly ImmediateRenderer immediateRenderer; - private readonly PickingRenderer pickingRenderer; - public readonly CommandList commandList; - public readonly Cam3D Camera; - private Renderable? cubeRenderable; + // The preview is drawn by the SAME raw-Vulkan renderer the level view uses, rebuilt whenever the + // selection changes. That is the point: a preview on a different renderer is a bad reference for + // the thing it is previewing, which is exactly what this tab exists to be. + private Engine.Rendering.Vulkan.VulkanRenderer? _vkPreview; + // Render-target size for the preview, distinct from previewHeight (the splitter position). + private uint previewTexWidth = 300, previewTexHeight = 300; + private bool previewDirty = true; + public readonly EditorCamera Camera; + private readonly List<(Vector3 a, Vector3 b, Vector4 color)> _debugLines = new(); private bool showSkeleton = true; - private bool pickRequested; // Picking granularity for this viewport only (never fed into the shared scene-picking used - // by View3D) — reuses local (bangleIndex, meshIndex) as the picking ID directly instead of + // by View3D) - reuses local (bangleIndex, meshIndex) as the picking ID directly instead of // minting a globally-unique ID per mesh, since only one asset is ever previewed here at a // time. bangleIndex is always 0 for Ties (no bangle concept). private (int bangleIndex, int meshIndex)? selectedMesh; private int selectedVertexIndex; private bool vertexEditMode; - // Screen-space pixel radii for the vertex-edit-mode overlay/picking — kept generous on the + // Screen-space pixel radii for the vertex-edit-mode overlay/picking - kept generous on the // pick radius specifically per the ask that vertex selection be tolerant, since a raw vertex // dot is a much smaller target than a mesh triangle. private const float VertexPointPixelRadius = 4f; @@ -157,22 +147,11 @@ public class AssetViewer : DockedFrame, ILevelListener private const float VertexPickPixelRadius = 10f; // ImmediateRenderer's DrawBillboard always uses white-source * this to produce, for any - // background pixel color C, a final color of (1,1,1) - C — i.e. the dot always reads as the + // background pixel color C, a final color of (1,1,1) - C - i.e. the dot always reads as the // inverse of whatever's behind it, so it stays visible regardless of the underlying texture // (this is the whole reason for this blend state instead of a fixed dot color). Alpha is left - // untouched (dest kept as-is) since only the color channels need inverting. - private static readonly BlendStateDescription InvertBlendState = new( - RgbaFloat.WHITE, - new BlendAttachmentDescription( - blendEnabled: true, - sourceColorFactor: BlendFactor.InverseDestinationColor, - destinationColorFactor: BlendFactor.Zero, - colorFunction: BlendFunction.Add, - sourceAlphaFactor: BlendFactor.Zero, - destinationAlphaFactor: BlendFactor.One, - alphaFunction: BlendFunction.Add)); - - // Persisted, user-draggable pane sizes (pixels) — each tracks the pane immediately BEFORE its + + // Persisted, user-draggable pane sizes (pixels) - each tracks the pane immediately BEFORE its // splitter; the trailing pane on the other side of a splitter always just takes whatever // GetContentRegionAvail() leaves over, so only one size needs to be stored per split. private float treeListWidth = 260f; @@ -180,14 +159,13 @@ public class AssetViewer : DockedFrame, ILevelListener private float assetInfoWidth = 320f; private const float SplitterThickness = 6f; - private List cachedRenderables = []; public List mobyAssets = []; public List tieAssets = []; public List ufragAssets = []; // UFrag-tab state. The lightmapped/not split is the first question worth asking of any UFrag and // eyeballing "lm -" across ~2000 rows doesn't scale, so it gets its own filter rather than - // reusing the Used/Unused one above — that one is meaningless here, since a UFrag is its own + // reusing the Used/Unused one above - that one is meaningless here, since a UFrag is its own // single placement and is therefore always "used". private bool? ufragLightmapFilter; private bool ufragShowUVOverlay = true; @@ -197,7 +175,9 @@ public class AssetViewer : DockedFrame, ILevelListener public bool IsDirty { get => isDirty; - set => isDirty = value; + // Any change that dirties the asset selection also invalidates the preview scene, which is + // built from that selection's meshes and materials. + set { isDirty = value; if (value) previewDirty = true; } } private MobyAsset? selectedMobyAsset; @@ -246,9 +226,9 @@ public UFragAsset? SelectedUFragAsset } } - // Lets the user rename an asset for export (textures/.bin/.gltf all take this name too — see + // Lets the user rename an asset for export (textures/.bin/.gltf all take this name too - see // ExportModel/GetExportName) instead of being stuck with the asset's raw internal name, which - // is routinely something like a full "levels/.../foo.entity.irb" path — not exactly what you + // is routinely something like a full "levels/.../foo.entity.irb" path - not exactly what you // want a Models Resource submission's files named after. Reset to blank (falls back to the // asset's own default name) whenever the selection changes, above. private string exportNameOverride = ""; @@ -260,19 +240,19 @@ private enum UsageFilter { All, Used, Unused } private UsageFilter assetUsageFilter = UsageFilter.All; // "Used" = has at least one placed instance in the currently loaded level (same definition - // "Find usages" below already uses) — recomputed once per TransmitAssets call rather than + // "Find usages" below already uses) - recomputed once per TransmitAssets call rather than // walking EntityManager.AllEntities() on every frame for every asset in the list. private HashSet usedMobyIds = []; private HashSet usedTieIds = []; // Moby materials are grouped per bangle (a material used by several bangles shows up under - // each) since bangles are independently toggleable — seeing which bangle actually pulls in a + // each) since bangles are independently toggleable - seeing which bangle actually pulls in a // material matters. Ties have no bangles, so their materials are just a flat deduped list. private readonly List<(int bangleIndex, List materials)> selectedMobyMaterialsByBangle = []; private readonly List selectedTieMaterials = []; // Placed instances of the currently selected asset found in the loaded level, populated on - // demand by the "Find usages" button (mirrors TexturesExplorer's usage lookup) — cleared + // demand by the "Find usages" button (mirrors TexturesExplorer's usage lookup) - cleared // whenever the selection changes so a stale result list from a previous asset can't linger. private List? mobyUsageResults; private List? tieUsageResults; @@ -281,29 +261,18 @@ public AssetViewer(GraphicsDevice gd) { FrameName = LM.Get("GUI_Frame_AssetViewer"); graphicsDevice = gd; - commandList = gd.ResourceFactory.CreateCommandList(); - Camera = new Cam3D( - gd, + // Zoom is handled manually in Tick(), gated on hovering the render image - same pattern the + // level view's camera uses. + Camera = new EditorCamera( new Vector3(0, 0, -10), Vector3.Zero, - 1f, Vector3.UnitY, - ProjectionType.Perspective, - // Custom, not Orbital: Orbital drives its own scroll-to-zoom internally with no - // notion of ImGui window/hover boundaries, which is why scrolling used to zoom this - // camera no matter where the cursor was. Zoom is handled manually in Tick() instead, - // gated on hovering the render image — same pattern View3D's camera already uses. - CameraMode.Custom, Program.Settings.CamFOV, 0.001f, 100f); - renderTexture = new RenderTexture2D(gd, 300u, 300u, true, (TextureSampleCount)Program.Settings.MSAA_Level); - renderer = new DecalAwareForwardRenderer(gd); - immediateRenderer = new ImmediateRenderer(gd); - pickingRenderer = new PickingRenderer(gd); } - /// Drops every reference to the level that's about to be unloaded — mobyAssets/ + /// Drops every reference to the level that's about to be unloaded - mobyAssets/ /// tieAssets wrap AssetManager-owned Models that are about to be disposed, and the selected- /// asset/usage-result state references entities from the same level. public void OnLevelUnloading() @@ -316,12 +285,15 @@ public void OnLevelUnloading() mobyAssets.Clear(); tieAssets.Clear(); // UFragAsset holds an IUFrag owned by the level being torn down, and the preview borrows the - // scene entity's mesh — both die with the level, so the list must not outlive it. + // scene entity's mesh - both die with the level, so the list must not outlive it. ufragAssets.Clear(); usedMobyIds.Clear(); usedTieIds.Clear(); - cachedRenderables.Clear(); assetManager = null; + // The preview renderer holds image views onto AssetManager's textures and GPU buffers built + // from meshes that are about to be destroyed, so it has to go with them - a preview left alive + // across a level unload would be sampling freed images on its next frame. + DisposePreview(); IsDirty = true; } @@ -405,7 +377,7 @@ static void AddMaterial(HashSet seen, List into, IMaterial mat else if (selectedUFragAsset != null) { // A UFrag is one mesh with one shader, so it reuses the Tie list rather than needing its - // own — the shader grid renders whatever is in there. + // own - the shader grid renders whatever is in there. AddMaterial(new HashSet(), selectedTieMaterials, selectedUFragAsset.Value.UFrag.Material); } } @@ -454,7 +426,7 @@ private static void RenderHierarchyNode(HierarchyNode node, string idPrefi } /// Compact "All / Used / Unused" radio row shared by both the Moby and Tie tabs below - /// — one filter for the whole asset library, same as the search box above it. + /// - one filter for the whole asset library, same as the search box above it. private void RenderUsageFilterControl() { int filter = (int)assetUsageFilter; @@ -526,7 +498,7 @@ private void RenderUFragFilterControl() private void RenderUFragLeaf(UFragAsset asset) { - // Identity is (zone, index), not an asset id — UFrags aren't keyed by TUID, and index alone + // Identity is (zone, index), not an asset id - UFrags aren't keyed by TUID, and index alone // repeats across zones. bool isSelected = selectedUFragAsset is { } sel && sel.ZoneId == asset.ZoneId && sel.Index == asset.Index; if (!asset.HasLightmap) ImGui.PushStyleColor(ImGuiCol.Text, ImGui.GetStyle().Colors[(int)ImGuiCol.TextDisabled]); @@ -555,6 +527,15 @@ public bool SelectTieById(ulong tieId) return true; } + public bool SelectUFragById(ulong ufragId) + { + var match = ufragAssets.FirstOrDefault(a => a.UFrag.Id == ufragId); + if (match.UFrag == null) return false; + + SelectedUFragAsset = match; + return true; + } + private static List FindMobyInstances(ulong mobyId) => EntityManager.Singleton.AllEntities().OfType().Where(e => e.BaseMoby.Id == mobyId).ToList(); @@ -606,8 +587,8 @@ private static void RenderUsageResults(List? results, string i } } - // Shader preview uses the material's albedo texture — same convention as the Shader Browser's - // own texture-reference thumbnails — since a shader has no rendering of its own worth showing. + // Shader preview uses the material's albedo texture - same convention as the Shader Browser's + // own texture-reference thumbnails - since a shader has no rendering of its own worth showing. private void RenderShaderGrid(IReadOnlyList materials, string columnsId) { int columns = Math.Max(1, (int)ImGui.GetContentRegionAvail().X / 72); @@ -714,6 +695,17 @@ protected override void Render(double deltaTime) ImGui.EndTabItem(); } + if (ImGui.BeginTabItem(LM.Get("GUI_Frame_AssetViewer_FoliageTab"))) + { + if (ImGui.BeginChild("asset_viewer_foliage_tab", ImGui.GetContentRegionAvail(), ImGuiChildFlags.Borders, ImGuiWindowFlags.AlwaysVerticalScrollbar)) + { + RenderFoliageList(); + } + ImGui.EndChild(); + + ImGui.EndTabItem(); + } + ImGui.EndTabBar(); } } @@ -727,118 +719,75 @@ protected override void Render(double deltaTime) previewHeight = Math.Clamp(previewHeight, 100f, Math.Max(100f, totalAvail.Y - 150f)); if (ImGui.BeginChild("asset_view", new Vector2(rightWidth, previewHeight), ImGuiChildFlags.Borders, ImGuiWindowFlags.NoScrollbar)) { + _viewport.Begin("asset_preview"); UpdateWindowSize(); Tick(deltaTime); + // Pushed every frame, same pattern the 3D view uses for its own render distance, so a + // change from the overlay slider (or a value loaded from disk) applies immediately. + Camera.FarPlane = Program.Settings.AssetViewerFarPlane; + Camera.Update(); - commandList.Begin(); - commandList.SetFramebuffer(renderTexture.Framebuffer); - commandList.ClearColorTarget(0, Bliss.CSharp.Colors.Color.LightBlue.ToRgbaFloat()); - commandList.ClearDepthStencil(1f); - - Camera.Begin(commandList); - Camera.Update(deltaTime); - // Depth test disabled: the skeleton overlay (see DrawSkeleton below) should always - // read on top of the mesh, not get hidden behind it when bones sit inside the model. - immediateRenderer.Begin(commandList, renderTexture.Framebuffer.OutputDescription, depthStencilState: DepthStencilStateDescription.DISABLED); - - if (selectedMobyAsset == null && selectedTieAsset == null && selectedUFragAsset == null) + if (previewDirty) { - if (IsDirty || cubeRenderable is null) - { - var cube = Primitives.CreateCube(graphicsDevice, new Material(GlobalResource.DefaultModelEffect)); - cube.Material.AddMaterialMap(new MaterialMapKey(MaterialMapType.Albedo), 0, new MaterialMap(GlobalResource.DefaultModelTexture, color: Bliss.CSharp.Colors.Color.White)); - - cubeRenderable = new Renderable(cube, new Transform - { - Rotation = Quaternion.Identity, - Scale = Vector3.One, - Translation = Vector3.Zero - }); - } - renderer.DrawRenderable(cubeRenderable!); - renderer.Draw(commandList, renderTexture.Framebuffer.OutputDescription); + RebuildPreview(); + previewDirty = false; } - else + + if (_vkPreview != null) { - if (IsDirty) - { - cachedRenderables.Clear(); - if (selectedMobyAsset != null) - { - var models = selectedMobyAsset.Value.Model; - var renderMap = selectedMobyAsset.Value.RenderModelMap; - for (int i = 0; i < models.Length; i++) - { - if (!renderMap[i]) continue; - foreach (var mesh in models[i].Meshes) - cachedRenderables.Add(new Renderable(mesh, new Transform { Rotation = Quaternion.Identity, Scale = Vector3.One, Translation = Vector3.Zero })); - } - } - else if (selectedTieAsset != null) - { - foreach (var mesh in selectedTieAsset.Value.Model.Meshes) - cachedRenderables.Add(new Renderable(mesh, new Transform { Rotation = Quaternion.Identity, Scale = Vector3.One, Translation = Vector3.Zero })); - } - else if (selectedUFragAsset != null && ResolveUFragMesh(selectedUFragAsset.Value) is { } ufragMesh) - { - // Scale matches EntityUFrag exactly (raw positions are fixed-point x256 on both - // engines) rather than being normalised per UFrag to fit the viewport. A - // per-selection scale would silently change the apparent lighting from one - // UFrag to the next - specular and the normal-map derivatives are not - // scale-invariant - and comparing bakes across UFrags is what this tab is for. - // The camera moves instead; see FrameUFragInPreview. - cachedRenderables.Add(new Renderable(ufragMesh, new Transform - { - Rotation = Quaternion.Identity, - Scale = Vector3.One / 256f, - Translation = -selectedUFragAsset.Value.localCentre / 256f, - })); - } + _debugLines.Clear(); + if (showSkeleton && selectedMobyAsset?.Moby.Skeleton is { } skeleton) + AppendSkeleton(skeleton); + if (vertexEditMode && ResolveSelectedMesh() is { } selectedMeshForOverlay) + AppendVertexOverlay(selectedMeshForOverlay); - IsDirty = false; - LunaLog.LogDebug($"Updated {cachedRenderables.Count} renderables"); + try + { + _vkPreview.SetDebugLines(_debugLines); + _vkPreview.Frame( + Camera.GetView(), Camera.GetProjection(), + _lighting.BuildLightData(Camera.Position), + NoVolumes, 0.1f, + selected: null, outlineColor: default, outlineThickness: 0f, + cameraPosition: Camera.Position, mobyDistanceCulling: false, + lit: Program.Settings.EnableLighting); + // Submitted straight away rather than deferred like the level view: this preview is + // a handful of draws, so there is nothing worth overlapping, and the renderer only + // advances a frame once its recording has actually been handed over. + _vkPreview.SubmitFrame(); } + catch (Exception e) { LunaLog.LogError($"[AssetViewer] preview frame failed: {e.Message}"); DisposePreview(); } + } - foreach (var renderable in cachedRenderables) - renderer.DrawRenderable(renderable); - renderer.Draw(commandList, renderTexture.Framebuffer.OutputDescription); - - if (showSkeleton && selectedMobyAsset?.Moby.Skeleton is { } skeleton) - DrawSkeleton(skeleton, immediateRenderer); + if (_vkPreview != null) + _viewport.DrawImage(LunaWindow.Instance.imGuiController.GetOrCreateImGuiBinding(graphicsDevice.ResourceFactory, _vkPreview.ColorTexture)); + else + _viewport.DrawEmpty(); - if (vertexEditMode && ResolveSelectedMesh() is { } selectedMeshForOverlay) - DrawVertexOverlay(selectedMeshForOverlay); - } + // Overlay, then picking. Same priority order as the level view (there is no gizmo in this + // one), and the order the calls are made in IS the order: TryConsumeClick answers true only + // for a click the toolbar did not want. Picking used to run before the image was even + // submitted, which is why a click on a toolbar button also moved the mesh selection. + DrawPreviewOverlay(); - if (pickRequested) + if (_viewport.TryConsumeClick()) { if (vertexEditMode && selectedMesh != null) PickVertexUnderCursor(); else PickMeshUnderCursor(); } - pickRequested = false; - - immediateRenderer.End(); - Camera.End(); - commandList.End(); - graphicsDevice.SubmitCommands(commandList); - ImGui.Image( - LunaWindow.Instance.imGuiController.GetOrCreateImGuiBinding(graphicsDevice.ResourceFactory, renderTexture.ColorTexture), - RenderFrameSize.GetSizeF(), - Vector2.Zero, - Vector2.One - ); + _viewport.End(); } ImGui.EndChild(); HorizontalSplitter("##split_preview", ref previewHeight, rightWidth); // Distance to Target (the orbit pivot), not Camera.Position.Length() (distance to world - // zero) — those were the same thing before middle-click pan could move Target away from + // zero) - those were the same thing before middle-click pan could move Target away from // Vector3.Zero, but "distance to origin" now means "distance to wherever the pivot is." - ImGui.Text($"{RenderFrameSize.Width}x{RenderFrameSize.Height} - Distance to target: {Vector3.Distance(Camera.Position, Camera.Target)}m"); + ImGui.Text($"{_viewport.PixelWidth}x{_viewport.PixelHeight} - Distance to target: {Vector3.Distance(Camera.Position, Camera.Target)}m"); ImGui.Separator(); // Lower part split vertically: asset info/shaders/export on the left (unchanged content), @@ -866,7 +815,7 @@ protected override void Render(double deltaTime) ExportModel(GltfExporter.ExportGltfSeparate, "gltf", GetExportName(mobyDefaultName), GetMobyGroups(moby), moby.Skeleton, ownFolder: true); ImGui.SameLine(); if (ImGui.Button(LM.Get("GUI_Frame_AssetViewer_ExportObj"))) - ExportModel(ObjExporter.Export, "obj", GetExportName(mobyDefaultName), GetMobyGroups(moby), moby.Skeleton); + ExportModel(ObjExporter.Export, "obj", GetExportName(mobyDefaultName), GetMobyGroups(moby), moby.Skeleton, ownFolder: true); ImGui.BeginGroup(); ImGui.Text("Id"); @@ -938,7 +887,7 @@ protected override void Render(double deltaTime) ExportModel(GltfExporter.ExportGltfSeparate, "gltf", GetExportName(tieAssetName), tieGroups, ownFolder: true); ImGui.SameLine(); if (ImGui.Button(LM.Get("GUI_Frame_AssetViewer_ExportObj"))) - ExportModel(ObjExporter.Export, "obj", GetExportName(tieAssetName), tieGroups); + ExportModel(ObjExporter.Export, "obj", GetExportName(tieAssetName), tieGroups, ownFolder: true); ImGui.BeginGroup(); ImGui.Text("Id"); @@ -984,19 +933,19 @@ protected override void Render(double deltaTime) /// Blank exportNameOverride falls back to the asset's own default name; otherwise the /// user's typed name is used verbatim (still gets sanitized for filesystem-illegal characters - /// by ExportModel below either way) — this is the one place that decides what name every + /// by ExportModel below either way) - this is the one place that decides what name every /// exported file (model, .bin, and every texture) ultimately gets built from. private string GetExportName(string defaultName) => string.IsNullOrWhiteSpace(exportNameOverride) ? defaultName : exportNameOverride; /// - /// Shared by every Moby/Tie export button — builds a sanitized output path under + /// Shared by every Moby/Tie export button - builds a sanitized output path under /// EditorPath/Exported/Models (asset names routinely contain path-like characters, e.g. /// "levels/great_clock_a/entities/.../foo.entity.irb", which would otherwise be interpreted /// as subdirectories) and hands off to ExportRunner for the actual background export + progress /// modal + result modal (shared with the whole-level export in GameBrowserFrame/FileMenuDraw). /// /// True for exporters that write more than one file alongside the - /// main one (e.g. GltfExporter.ExportGltfSeparate's .bin + texture PNGs) — puts the asset in + /// main one (e.g. GltfExporter.ExportGltfSeparate's .bin + texture PNGs) - puts the asset in /// its own Exported/Models/<name>/ folder instead of dropping several loose files /// directly into Exported/Models next to every other asset's exports. private static void ExportModel(Action, ISkeleton?, Action?> exporter, string extension, string assetName, IReadOnlyList groups, ISkeleton? skeleton = null, bool ownFolder = false) @@ -1011,21 +960,21 @@ private static void ExportModel(Action, progress => exporter(path, safeName, groups, skeleton, progress)); } - /// One MeshGroup per bangle (indexed name fallback for unnamed bangles) — keeps + /// One MeshGroup per bangle (indexed name fallback for unnamed bangles) - keeps /// bangles as distinct submeshes/nodes on export instead of flattening the whole Moby into a /// single mesh, since bangles are independently toggleable parts (see RenderModelMap above), /// not interchangeable LOD/skin variants. /// The scene entity's own already-built GPU mesh for this UFrag, or null if the level /// produced no entity for it. Borrowed, never owned: building a second Mesh here would duplicate - /// the vertex buffer AND detach the preview from the material the 3D view actually renders with — + /// the vertex buffer AND detach the preview from the material the 3D view actually renders with - /// including its bound lightmap atlases, which is the whole point of previewing a UFrag. - private static Bliss.CSharp.Geometry.Meshes.IMesh? ResolveUFragMesh(UFragAsset asset) => + private static RenderMesh? ResolveUFragMesh(UFragAsset asset) => EntityManager.Singleton.AllEntities().OfType() .FirstOrDefault(e => ReferenceEquals(e.UFrag, asset.UFrag))?.UFragMesh; /// Pulls the camera back far enough to frame the selected UFrag. Necessary because the /// mesh keeps its true 1/256 scale (see the renderable build) and UFrags vary from a few world - /// units across to tens — a fixed camera distance shows either a speck or the inside of a wall. + /// units across to tens - a fixed camera distance shows either a speck or the inside of a wall. /// Clamped under the camera's 100f far plane so a large chunk can't land entirely beyond it. private void FrameUFragInPreview(UFragAsset asset) { @@ -1036,7 +985,7 @@ private void FrameUFragInPreview(UFragAsset asset) } /// Export payload for a UFrag: one mesh, one shader. Positions are descaled by 256 to - /// world units, and the placement ANCHOR is deliberately not applied — the export is asset-local, + /// world units, and the placement ANCHOR is deliberately not applied - the export is asset-local, /// matching Moby/Tie export, so a UFrag lands at the origin rather than wherever it sits in the /// level. Real normals/tangents are passed through so GeometryData doesn't recompute them from /// triangles when the file already told us (its tangent handedness is still derived, as always). @@ -1077,7 +1026,7 @@ private void RenderUFragPanel(UFragAsset asset) ExportModel(GltfExporter.ExportGltfSeparate, "gltf", exportName, GetUFragGroups(asset, exportName), ownFolder: true); ImGui.SameLine(); if (ImGui.Button(LM.Get("GUI_Frame_AssetViewer_ExportObj"))) - ExportModel(ObjExporter.Export, "obj", exportName, GetUFragGroups(asset, exportName)); + ExportModel(ObjExporter.Export, "obj", exportName, GetUFragGroups(asset, exportName), ownFolder: true); ImGuiPlus.HelpMarker(LM.Get("GUI_Frame_AssetViewer_UFragExportNote")); ImGui.BeginGroup(); @@ -1128,7 +1077,7 @@ private void RenderUFragPanel(UFragAsset asset) /// The baked-lighting readout: which atlas entry this UFrag resolves to, the UV rectangle /// its vertices occupy, and the atlases themselves with the UV island drawn on top. /// - /// The rect and the overlay separate the two failure modes that look identical on screen — a UFrag + /// The rect and the overlay separate the two failure modes that look identical on screen - a UFrag /// rendering black because its atlas region genuinely IS black, versus because it is addressing the /// wrong region. That distinction is what caught the UVs2 decode bug (islands were landing about /// two texels wide, see UFragVertex.UVs2), so it stays even though that particular bug is fixed. @@ -1166,8 +1115,8 @@ private void RenderUFragBakedSection(UFragAsset asset) // view. They drive THIS frame's own renderer instance, which is also why the UV overlay below // reads its transform from the same place: the overlay has to describe the shader that drew // the image next to it, or it lies. - if (renderer is DecalAwareForwardRenderer lit) { + var lit = _lighting; ImGui.SeparatorText(LM.Get("GUI_Frame_AssetViewer_UFragPreviewSection")); if (!Program.Settings.EnableLighting) ImGui.TextDisabled(LM.Get("GUI_Frame_AssetViewer_UFragNeedsLighting")); @@ -1212,18 +1161,18 @@ private void RenderUFragBakedSection(UFragAsset asset) } /// Projects this UFrag's lightmap UVs onto the atlas image just drawn. - private void DrawUFragUVOverlay(IUFrag ufrag, Vector2 origin, float size, Texture2D atlas) + private void DrawUFragUVOverlay(IUFrag ufrag, Vector2 origin, float size, GpuTexture atlas) { var uvs = ufrag.GetLightmapUVs(); if (uvs == null || uvs.Length < 6) return; - // Read from THIS frame's renderer, not View3D's — the overlay must describe the shader that - // produced the preview beside it. Identity in normal use; the fields exist as research knobs. - var lit = renderer as DecalAwareForwardRenderer; - Vector2 scale = lit?.LightmapUVScale ?? Vector2.One; - Vector2 offset = lit?.LightmapUVOffset ?? Vector2.Zero; - Vector2 pivot = lit?.LightmapUVPivot ?? new Vector2(0.5f, 0.5f); - float rotDeg = lit?.LightmapUVRotation ?? 0f; + // Read from THIS frame's lighting state, not the level view's: the overlay must describe the + // shader that produced the preview beside it. Identity in normal use; these are research knobs. + var lit = _lighting; + Vector2 scale = lit.LightmapUVScale; + Vector2 offset = lit.LightmapUVOffset; + Vector2 pivot = lit.LightmapUVPivot; + float rotDeg = lit.LightmapUVRotation; float sin = MathF.Sin(rotDeg * MathF.PI / 180f); float cos = MathF.Cos(rotDeg * MathF.PI / 180f); @@ -1236,7 +1185,7 @@ Vector2 Transform(float u, float v) } // The atlas is drawn with uv0=(0,1)/uv1=(1,0), i.e. V-FLIPPED, so v=1 is at the top of the - // image. Screen Y therefore uses (1 - v) — forgetting this silently mirrors the overlay. + // image. Screen Y therefore uses (1 - v) - forgetting this silently mirrors the overlay. Vector2 ToScreen(Vector2 uv) => new(origin.X + uv.X * size, origin.Y + (1f - uv.Y) * size); var draw = ImGui.GetWindowDrawList(); @@ -1307,7 +1256,7 @@ private void DrawUFragHexDump(string label, int baseOffset, byte[]? data) if (!ufragShowFloatInterpretation || data.Length < 4) return; - // Big-endian, and aligned to the FILE's absolute offset rather than this array's start — a real + // Big-endian, and aligned to the FILE's absolute offset rather than this array's start - a real // float field sits on a real 4-byte boundary, so aligning to the array splits every value. var floatSb = new System.Text.StringBuilder(); int firstAligned = (4 - (baseOffset & 3)) & 3; @@ -1322,25 +1271,6 @@ private void DrawUFragHexDump(string label, int baseOffset, byte[]? data) private static List GetMobyGroups(IMoby moby) => moby.Bangles.Select((bangle, i) => new MeshGroup(string.IsNullOrEmpty(bangle.Name) ? $"Bangle_{i}" : bangle.Name, bangle.Meshes)).ToList(); - /// - /// Draws each bone-to-parent segment as a red line, using WorldBindPose's translation - /// directly with no extra scale applied — unlike the raw fixed-point vertex positions - /// (MobyMesh.GetBuffers multiplies those by moby.Scale), the skeleton's tms0/tms1 matrices are - /// plain floats already in the same absolute space the scaled mesh geometry ends up in - /// (confirmed against InsomniaToolset: its glTF exporter applies meshScale only to the vertex - /// position attribute, never to the skeleton matrices). The preview's own meshes are drawn at - /// an identity Transform, so no further placement transform belongs here either. - /// - private static void DrawSkeleton(ISkeleton skeleton, ImmediateRenderer immediateRenderer) - { - foreach (var bone in skeleton.Bones) - { - if (bone.ParentIndex < 0) continue; - var parent = skeleton.Bones[bone.ParentIndex]; - immediateRenderer.DrawLine(parent.WorldBindPose.Translation, bone.WorldBindPose.Translation, Bliss.CSharp.Colors.Color.Red); - } - } - public override void RenderAsWindow(double deltaTime) { ImGui.SetNextWindowPos(DefaultPosition, DockingConditions, new Vector2(0.5f)); @@ -1350,82 +1280,38 @@ public override void RenderAsWindow(double deltaTime) private void Tick(double deltaTime) { - RenderFramePos = ImGui.GetCursorScreenPos(); - var wcravail = ImGui.GetContentRegionAvail(); - int width = (int)wcravail.X, - height = (int)wcravail.Y; - - RenderFrameSize = new Rectangle((int)RenderFramePos.X, (int)RenderFramePos.Y, width, height); - var windowMousePos = Input.GetMousePosition(); - - // RenderFrameSize's origin is already RenderFramePos (absolute screen coords, unlike - // View3D's zero-relative FrameContentRegion) — adding RenderFrameSize.GetOriginF() here - // on top of RenderFramePos double-subtracted it, shifting every pick by an extra - // -RenderFramePos and throwing off exactly the click-to-viewport mapping this was for. - MousePos = windowMousePos - RenderFramePos; - - Point absMousePos = new((int)windowMousePos.X, (int)windowMousePos.Y); - bool isHoveringWnd = ImGui.IsWindowHovered(); - bool isMouseInCntReg = RenderFrameSize.Contains(absMousePos); - CheckCameraDragInput(isMouseInCntReg); + // The viewport measured the region and sampled the mouse in Begin, and it latched the left + // click for TryConsumeClick to hand over once the toolbar has had its turn. + CheckCameraDragInput(_viewport.AllowCameraInput); // Scroll-zoom is independent of the RMB rotate-drag and gated purely on hovering the - // render image, not "anywhere in the window" — otherwise scrolling while reading the + // render image, not "anywhere in the window": otherwise scrolling while reading the // asset details panel or browsing the hierarchy would zoom the preview too. // MoveToTarget (not Position +=) keeps Target fixed on the asset while dollying Position - // along the view axis — Position += would drag the orbit pivot off the asset every zoom. - if (isHoveringWnd && isMouseInCntReg && Input.IsMouseScrolling(out var scrollDelta)) + // along the view axis; Position += would drag the orbit pivot off the asset every zoom. + if (_viewport.AllowCameraInput && Input.IsMouseScrolling(out var scrollDelta)) Camera.MoveToTarget(-scrollDelta.Y * 0.5f); - - // Left click picks a bangle/mesh under the cursor — independent of the RMB orbit-drag - // above (different button, no gizmo in this viewport to conflict with). - if (isHoveringWnd && isMouseInCntReg && Input.IsMouseButtonPressed(MouseButton.Left)) - pickRequested = true; } /// - /// GPU color-ID picking scoped to this viewport's own preview model (same PickingRenderer + /// GPU colour-ID picking scoped to this viewport's own preview model (the same renderer /// class View3D uses for whole-entity picking, but the id here is packed straight from local - /// (bangleIndex, meshIndex) instead of a globally-unique per-mesh id — this viewport only ever + /// (bangleIndex, meshIndex) instead of a globally-unique per-mesh id - this viewport only ever /// shows one asset at a time, so there's no cross-asset collision risk to design around. /// bangleIndex is always 0 for Ties. /// private void PickMeshUnderCursor() { - if (RenderFrameSize.Width <= 0 || RenderFrameSize.Height <= 0) return; - - var entries = new List<(Bliss.CSharp.Geometry.Meshes.IMesh mesh, Matrix4x4 world, uint id)>(); - if (selectedMobyAsset != null) - { - var models = selectedMobyAsset.Value.Model; - var renderMap = selectedMobyAsset.Value.RenderModelMap; - for (int bangleIndex = 0; bangleIndex < models.Length; bangleIndex++) - { - if (!renderMap[bangleIndex]) continue; - var meshes = models[bangleIndex].Meshes; - for (int meshIndex = 0; meshIndex < meshes.Length; meshIndex++) - entries.Add((meshes[meshIndex], Matrix4x4.Identity, (uint)((bangleIndex << 16) | meshIndex))); - } - } - else if (selectedTieAsset != null) - { - var meshes = selectedTieAsset.Value.Model.Meshes; - for (int meshIndex = 0; meshIndex < meshes.Length; meshIndex++) - entries.Add((meshes[meshIndex], Matrix4x4.Identity, (uint)meshIndex)); - } - else - { - return; - } + if (!_viewport.HasArea) return; + if (_vkPreview == null || selectedUFragAsset != null) return; uint hitId; try { - hitId = pickingRenderer.Pick( - (uint)RenderFrameSize.Width, (uint)RenderFrameSize.Height, - (int)MousePos.X, (int)MousePos.Y, - Camera.GetView() * Camera.GetProjection(), - entries); + hitId = _vkPreview.Pick( + Camera.GetView(), Camera.GetProjection(), + (int)_viewport.MousePos.X, (int)_viewport.MousePos.Y, + _viewport.Size.X, _viewport.Size.Y); } catch (Exception e) { @@ -1433,7 +1319,7 @@ private void PickMeshUnderCursor() return; } - if (hitId == PickingRenderer.NoHit) + if (hitId == Engine.Rendering.Vulkan.VulkanRenderer.NoHit) { selectedMesh = null; return; @@ -1445,7 +1331,7 @@ private void PickMeshUnderCursor() } /// Resolves selectedMesh's (bangleIndex, meshIndex) back to the engine-level IMesh - /// (not the Bliss Model used by PickMeshUnderCursor/rendering) — shared by the info panel, + /// (not the GPU-side Model used by PickMeshUnderCursor/rendering), shared by the info panel, /// vertex-edit-mode picking, and its overlay, since all three need VertexDumper/raw vertex /// positions rather than the GPU-side mesh. private IMesh? ResolveSelectedMesh() @@ -1469,13 +1355,13 @@ private void PickMeshUnderCursor() } /// CPU screen-space nearest-vertex picking against the selected mesh's raw vertex - /// positions, rather than a second GPU picking pass — these preview meshes are small enough + /// positions, rather than a second GPU picking pass - these preview meshes are small enough /// (single asset, not a whole level) that projecting every vertex per click is cheap, and it /// sidesteps rasterizing sub-pixel point primitives with a click-tolerant hit radius, which a /// GPU ID buffer can't easily give without inflating actual triangle geometry. private void PickVertexUnderCursor() { - if (RenderFrameSize.Width <= 0 || RenderFrameSize.Height <= 0) return; + if (!_viewport.HasArea) return; var mesh = ResolveSelectedMesh(); if (mesh == null) return; @@ -1491,7 +1377,7 @@ private void PickVertexUnderCursor() var worldPos = new Vector3(positions[i * 3], positions[i * 3 + 1], positions[i * 3 + 2]); if (!TryProjectToScreen(worldPos, viewProj, out Vector2 screen)) continue; - float dx = screen.X - MousePos.X, dy = screen.Y - MousePos.Y; + float dx = screen.X - _viewport.MousePos.X, dy = screen.Y - _viewport.MousePos.Y; float distSq = dx * dx + dy * dy; if (distSq < bestDistSq) { @@ -1515,52 +1401,14 @@ private bool TryProjectToScreen(Vector3 worldPos, Matrix4x4 viewProj, out Vector Vector3 ndc = new(clip.X / clip.W, clip.Y / clip.W, clip.Z / clip.W); screen = new Vector2( - (ndc.X * 0.5f + 0.5f) * RenderFrameSize.Width, - (1f - (ndc.Y * 0.5f + 0.5f)) * RenderFrameSize.Height); + (ndc.X * 0.5f + 0.5f) * _viewport.Size.X, + (1f - (ndc.Y * 0.5f + 0.5f)) * _viewport.Size.Y); return true; } - /// Vertex-edit-mode overlay: one billboard dot per vertex of the selected mesh, - /// blended with InvertBlendState so each dot always reads against its background regardless - /// of the underlying texture/lighting. The vertex currently backing the raw-dump panel - /// (selectedVertexIndex) is drawn larger so it's unambiguous which one is picked. - private void DrawVertexOverlay(IMesh mesh) - { - float[] positions = mesh.Geometry.GetVertexPositions(); - int vertexCount = positions.Length / 3; - if (vertexCount == 0) return; - - immediateRenderer.PushBlendState(InvertBlendState); - immediateRenderer.PushDepthStencilState(DepthStencilStateDescription.DEPTH_ONLY_LESS_EQUAL_READ); - - for (int i = 0; i < vertexCount; i++) - { - var worldPos = new Vector3(positions[i * 3], positions[i * 3 + 1], positions[i * 3 + 2]); - float pixelRadius = i == selectedVertexIndex ? SelectedVertexPixelRadius : VertexPointPixelRadius; - float scale = WorldScaleForPixelRadius(worldPos, pixelRadius); - immediateRenderer.DrawBillboard(worldPos, new Vector2(scale), Bliss.CSharp.Colors.Color.White); - } - - immediateRenderer.PopDepthStencilState(); - immediateRenderer.PopBlendState(); - } - - // DrawBillboard sizes its quad off GlobalResource.DefaultImmediateRendererTexture's 1x1 - // source rect (half-size = (Width/100)/2 = 0.005 world units per unit of `scale`, since no - // texture is pushed before calling it here) — back-solve the `scale` that makes the billboard - // cover pixelRadius screen pixels at this vertex's current distance from the camera, so every - // dot stays a roughly constant on-screen size regardless of mesh scale or camera zoom. - private float WorldScaleForPixelRadius(Vector3 worldPos, float pixelRadius) - { - float distance = Vector3.Distance(Camera.Position, worldPos); - float fovYRad = Camera.Fov * (MathF.PI / 180f); - float worldHalfSize = 2f * distance * MathF.Tan(fovYRad * 0.5f) * (pixelRadius / Math.Max(1, RenderFrameSize.Height)); - return worldHalfSize / 0.005f; - } - - /// Right-hand column of the lower split — metadata + raw vertex data for whatever + /// Right-hand column of the lower split - metadata + raw vertex data for whatever /// PickMeshUnderCursor last selected. Resolves back through the engine-level Moby/Tie mesh - /// list (not the Bliss Model used for picking/rendering) since that's what still has + /// list (not the GPU-side Model used for picking/rendering) since that's what still has /// IMesh.VertexDumper/VertexFormatName and the real Material. private void RenderSelectedMeshPanel() { @@ -1612,7 +1460,7 @@ private void RenderSelectedMeshPanel() ImGui.TextUnformatted(dump ?? LM.Get("GUI_Frame_AssetViewer_VertexOutOfRange")); } - /// Draggable divider between two side-by-side panes — mutates + /// Draggable divider between two side-by-side panes - mutates /// (the pane immediately to its left) by the horizontal mouse delta while dragged. Caller /// clamps before using it; this only applies the raw delta. private static void VerticalSplitter(string id, ref float width, float height) @@ -1626,7 +1474,7 @@ private static void VerticalSplitter(string id, ref float width, float height) ImGui.SameLine(0, 0); } - /// Draggable divider between two stacked panes — mutates + /// Draggable divider between two stacked panes - mutates /// (the pane immediately above it) by the vertical mouse delta while dragged. private static void HorizontalSplitter(string id, ref float height, float width) { @@ -1637,32 +1485,22 @@ private static void HorizontalSplitter(string id, ref float height, float width) height += ImGui.GetIO().MouseDelta.Y; } - // Tracks the previous frame's drag state so the shared NoMouse flag (below) is only touched - // on a rising/falling edge, not every frame. - private bool wasDragging; - /// RMB drags orbit (rotates Position around the fixed Target); MMB drags pan (moves /// Position and Target together, so the orbit origin itself relocates instead of just /// spinning around it). Both share one method rather than two independent ones because they - /// also share the ImGuiConfigFlags.NoMouse relative-mouse-mode flag: two separate methods each - /// unconditionally setting/clearing that flag would have the second one clobber whatever the - /// first just set whenever only one of the two buttons is actually held. + /// also share relative-mouse-mode: two separate methods each reporting their own drag state + /// would have the second one cancel whatever the first just started whenever only one of the + /// two buttons is actually held. private void CheckCameraDragInput(bool allowGrab) { - ImGuiIOPtr io = ImGui.GetIO(); bool rotating = rmbghandler.TryGrabMouse(allowGrab); bool panning = mmbghandler.TryGrabMouse(allowGrab); bool isDragging = rotating || panning; - // Edge-triggered, not level-triggered: NoMouse is also written by View3D's own drag - // handling (same relative-mouse-mode pattern, different viewport). Unconditionally - // clearing it every frame this viewport has nothing grabbed — what this used to do — would - // cut off a drag in progress over there if both frames tick within the same pass. - if (isDragging && !wasDragging) - io.ConfigFlags |= ImGuiConfigFlags.NoMouse; - else if (!isDragging && wasDragging) - io.ConfigFlags &= ~ImGuiConfigFlags.NoMouse; - wasDragging = isDragging; + // The viewport owns relative mouse mode. It is one global flag shared with the level view, so + // only whichever viewport turned it on turns it off again; this used to be hand-rolled here + // with an edge tracker precisely because the other view kept clobbering it. + _viewport.SetMouseCaptured(isDragging); if (!isDragging) return; @@ -1673,7 +1511,7 @@ private void CheckCameraDragInput(bool allowGrab) Vector2 rot = delta * Program.Settings.CamSensivity; // rotateAroundTarget: true swings Position around the fixed Target (real orbit). - // false — what this used to pass — keeps Position fixed and swings Target instead, + // false - what this used to pass - keeps Position fixed and swings Target instead, // which is FPS-style look, not an orbit; that's why this never actually orbited. Camera.SetPitch(Camera.GetPitch() - rot.Y, true); Camera.SetYaw(Camera.GetYaw() - rot.X, true); @@ -1688,10 +1526,10 @@ private void CheckCameraDragInput(bool allowGrab) // tools. float distance = Vector3.Distance(Camera.Position, Camera.Target); float fovYRad = Camera.Fov * (MathF.PI / 180f); - float worldUnitsPerPixel = 2f * distance * MathF.Tan(fovYRad * 0.5f) / Math.Max(1, RenderFrameSize.Height); + float worldUnitsPerPixel = 2f * distance * MathF.Tan(fovYRad * 0.5f) / Math.Max(1, _viewport.PixelHeight); // Built by hand instead of Cam3D.MoveRight/MoveUp: those use GetRight() = Cross(Forward, - // Up) and the raw Up field directly, neither of which is normalized — Up drifts and + // Up) and the raw Up field directly, neither of which is normalized - Up drifts and // isn't guaranteed orthogonal to Forward after SetPitch/SetRoll, so pan speed would // vary with pitch (shrinking toward zero looking straight up/down) and drift over time. // right/up here are a proper orthonormal basis for the current view. @@ -1700,7 +1538,7 @@ private void CheckCameraDragInput(bool allowGrab) Vector3 up = Vector3.Normalize(Vector3.Cross(right, forward)); // Signs make the dragged point track the cursor (drag right -> content follows right, - // i.e. camera moves left; drag down -> content follows down, i.e. camera moves up) — + // i.e. camera moves left; drag down -> content follows down, i.e. camera moves up) - // not runtime-verified; if the pan feels inverted, flip both signs here. Vector3 shift = right * (-delta.X * worldUnitsPerPixel) + up * (delta.Y * worldUnitsPerPixel); Camera.Position += shift; @@ -1710,15 +1548,333 @@ private void CheckCameraDragInput(bool allowGrab) private void UpdateWindowSize() { - if (RenderFrameSize.Width <= 0 || RenderFrameSize.Height <= 0) return; + if (!_viewport.HasArea) return; - if ((int)renderTexture.Width != RenderFrameSize.Width || (int)renderTexture.Height != RenderFrameSize.Height) + if (previewTexWidth != (uint)_viewport.PixelWidth || previewTexHeight != (uint)_viewport.PixelHeight) OnResize(); } + private static readonly IReadOnlyList<(Matrix4x4 world, Vector4 color, uint pickId)> NoVolumes = + Array.Empty<(Matrix4x4, Vector4, uint)>(); + + // The preview's own lighting state. Separate from the level view's so research controls there do + // not silently change what this tab shows. + private readonly SceneLighting _lighting = new(); + + // Placeholder shown when nothing is selected, so the viewport is never just an empty rectangle. + // Registered with the capture registry once, under its own key, exactly like real asset geometry - + // that is what lets the normal preview path draw it with no special case beyond this. + private RenderMesh? _placeholderMesh; + private float[]? _placeholderVertexData; + private uint[]? _placeholderIndices; + private RenderMaterial? _placeholderMaterial; + private GpuTexture? _placeholderTexture; + + private RenderMesh? EnsurePlaceholderCube() + { + if (_placeholderMesh is { } existing) + { + // Unloading a level clears the capture registry, which drops this registration with it - + // so re-register rather than assuming it survived, or the placeholder silently disappears + // the first time a level is closed. + if (!Engine.Rendering.Vulkan.VulkanSceneCapture.TryGet(existing, out _)) + Engine.Rendering.Vulkan.VulkanSceneCapture.Register(existing, _placeholderVertexData!, _placeholderIndices!); + return existing; + } + + // Unit cube: 24 vertices (per-face normals, so the faces shade distinctly) and 12 triangles. + var vertices = new List(24); + var indices = new List(36); + Vector3[] normals = + [ + Vector3.UnitX, -Vector3.UnitX, Vector3.UnitY, + -Vector3.UnitY, Vector3.UnitZ, -Vector3.UnitZ, + ]; + foreach (var n in normals) + { + // Two in-plane axes for this face, from the normal. + Vector3 u = MathF.Abs(n.Y) > 0.5f ? Vector3.UnitX : Vector3.UnitY; + Vector3 tangent = Vector3.Normalize(Vector3.Cross(u, n)); + Vector3 bitangent = Vector3.Cross(n, tangent); + uint baseIndex = (uint)vertices.Count; + for (int corner = 0; corner < 4; corner++) + { + float sx = (corner == 0 || corner == 3) ? -0.5f : 0.5f; + float sy = corner < 2 ? -0.5f : 0.5f; + Vector3 position = n * 0.5f + tangent * sx + bitangent * sy; + vertices.Add(new Vertex3D( + position, + new Vector2(sx + 0.5f, sy + 0.5f), + Vector2.Zero, + n, + new Vector4(tangent, 1f), + Vector4.One)); + } + indices.AddRange([baseIndex, baseIndex + 1, baseIndex + 2, baseIndex, baseIndex + 2, baseIndex + 3]); + } + + // A real 1x1 white albedo, not an empty material. The renderer substitutes SOME texture for an + // unbound slot, but it picks that fallback from the scene's own materials, and when nothing is + // selected this cube is the whole scene: leaving it textureless makes the renderer refuse to + // build at all, which shows up as an empty viewport exactly when the placeholder is the point. + _placeholderTexture ??= GpuTexture.Solid(graphicsDevice, 255, 255, 255, 255); + _placeholderMaterial = new RenderMaterial(); + _placeholderMaterial.AddMaterialMap(MaterialMapType.Albedo, new MaterialMap(_placeholderTexture)); + + var vertexArray = vertices.ToArray(); + var indexArray = indices.ToArray(); + var mesh = new RenderMesh(vertexArray, indexArray, _placeholderMaterial); + + _placeholderVertexData = Engine.Rendering.Vulkan.VulkanSceneCapture.Interleave(vertexArray); + _placeholderIndices = indexArray; + Engine.Rendering.Vulkan.VulkanSceneCapture.Register(mesh, _placeholderVertexData, _placeholderIndices); + + _placeholderMesh = mesh; + return mesh; + } + + private bool showClipControls; + + /// Toolbar drawn over the preview image. Kept to controls that describe THIS viewport - + /// anything scene-wide belongs in the settings frame, not floating over a preview. + private void DrawPreviewOverlay() + { + var overlay = _viewport.Overlay; + overlay.ToggleButton("C", ref showClipControls, LM.Get("GUI_Frame_AssetViewer_ClipControls")); + + if (showClipControls && overlay.BeginPanel("clip", new Vector2(280f, 0f))) + { + float farPlane = Program.Settings.AssetViewerFarPlane; + ImGui.SetNextItemWidth(-1f); + // Logarithmic: the useful range spans a UFrag previewed at 1/256 scale up to a large tie, + // which a linear slider cannot resolve at both ends. + if (ImGui.SliderFloat("##far", ref farPlane, 1f, 10000f, LM.Get("GUI_Frame_AssetViewer_FarClip"), ImGuiSliderFlags.Logarithmic)) + Program.Settings.AssetViewerFarPlane = farPlane; + if (ImGui.SmallButton(LM.Get("GUI_Common_Reset"))) + Program.Settings.AssetViewerFarPlane = 100f; + overlay.EndPanel(); + } + } + + private void DisposePreview() + { + _vkPreview?.Dispose(); + _vkPreview = null; + } + + /// Rebuilds the preview scene from the current selection. The whole renderer is recreated + /// rather than patched: a selection change replaces every mesh and material in it, and it only + /// happens when the user clicks an asset. + private void RebuildPreview() + { + DisposePreview(); + + var verts = new List(); + var idx = new List(); + var materials = new List(); + var instances = new List<(int, int, Matrix4x4, Vector4, object, float, uint)>(); + var geoRemap = new Dictionary(); + var matRemap = new Dictionary(ReferenceEqualityComparer.Instance); + + void Add(RenderMesh mesh, Matrix4x4 world, uint pickId) + { + if (!Engine.Rendering.Vulkan.VulkanSceneCapture.TryGet(mesh, out int gi)) return; + if (!geoRemap.TryGetValue(gi, out int geoSlot)) + { + geoSlot = verts.Count; + verts.Add(Engine.Rendering.Vulkan.VulkanSceneCapture.VertexData[gi]); + idx.Add(Engine.Rendering.Vulkan.VulkanSceneCapture.Indices[gi]); + geoRemap[gi] = geoSlot; + } + var material = mesh.Material; + if (!matRemap.TryGetValue(material, out int matSlot)) + { + matSlot = materials.Count; + materials.Add(Engine.Rendering.Vulkan.VkMaterialBuilder.Build(material, assetManager)); + matRemap[material] = matSlot; + } + // A bounding sphere big enough that the renderer's frustum cull never drops preview + // geometry: the camera is framed on the asset by FrameAssetInPreview, and a preview that + // culls what it is previewing is never what the user wants. + instances.Add((geoSlot, matSlot, world, new Vector4(0f, 0f, 0f, 1e9f), null!, -1f, pickId)); + } + + ForEachPreviewMesh(Add); + + if (instances.Count == 0) return; + + // Null when no level is loaded (the placeholder case) - the renderer falls back to a neutral + // 1x1 cube, which contributes nothing because EnvironmentIntensity is 0 without a level. + var envCube = assetManager?.EnvironmentCubemapView?.Target; + + try + { + _vkPreview = new Engine.Rendering.Vulkan.VulkanRenderer( + graphicsDevice, verts, idx, materials, instances, envCube, previewTexWidth, previewTexHeight) + { + // The preview has always had a light background; the level view keeps its dark one. + ClearColour = new Vector4(0.68f, 0.85f, 0.90f, 1f), + }; + } + catch (Exception e) + { + LunaLog.LogError($"[AssetViewer] preview init failed: {e.Message}"); + _vkPreview = null; + } + } + + /// Walks the selected asset's drawable meshes, handing each one its world transform and + /// its pick id. One place, so rendering and picking can never disagree about what is on screen - + /// they used to build that list separately. + private void ForEachPreviewMesh(Action add) + { + if (selectedMobyAsset != null) + { + var models = selectedMobyAsset.Value.Model; + var renderMap = selectedMobyAsset.Value.RenderModelMap; + for (int bangleIndex = 0; bangleIndex < models.Length; bangleIndex++) + { + if (!renderMap[bangleIndex]) continue; + var meshes = models[bangleIndex].Meshes; + for (int meshIndex = 0; meshIndex < meshes.Length; meshIndex++) + add(meshes[meshIndex], Matrix4x4.Identity, (uint)((bangleIndex << 16) | meshIndex)); + } + } + else if (selectedTieAsset != null) + { + var meshes = selectedTieAsset.Value.Model.Meshes; + for (int meshIndex = 0; meshIndex < meshes.Length; meshIndex++) + add(meshes[meshIndex], Matrix4x4.Identity, (uint)meshIndex); + } + else if (selectedUFragAsset == null) + { + if (EnsurePlaceholderCube() is { } placeholder) + add(placeholder, Matrix4x4.Identity, uint.MaxValue); + } + else if (ResolveUFragMesh(selectedUFragAsset.Value) is { } ufragMesh) + { + // Scale matches EntityUFrag exactly (raw positions are fixed-point x256 on both engines) + // rather than being normalised per UFrag to fit the viewport. A per-selection scale would + // silently change the apparent lighting from one UFrag to the next - specular and the + // normal-map derivatives are not scale-invariant - and comparing bakes across UFrags is + // what this tab is for. The camera moves instead; see FrameUFragInPreview. + var world = Matrix4x4.CreateScale(1f / 256f) + * Matrix4x4.CreateTranslation(-selectedUFragAsset.Value.localCentre / 256f); + add(ufragMesh, world, 0u); + } + } + + private void AppendSkeleton(ISkeleton skeleton) + { + var red = new Vector4(1f, 0f, 0f, 1f); + foreach (var bone in skeleton.Bones) + { + if (bone.ParentIndex < 0) continue; + var parent = skeleton.Bones[bone.ParentIndex]; + _debugLines.Add((parent.WorldBindPose.Translation, bone.WorldBindPose.Translation, red)); + } + } + + /// Vertex markers as small screen-scaled crosses. These were billboarded quads before; + /// a cross is what the debug-line overlay can draw, and it marks a point at least as precisely. + /// The screen-space sizing is unchanged, so a dot stays the same size at any zoom or mesh scale. + private void AppendVertexOverlay(IMesh mesh) + { + float[] positions = mesh.Geometry.GetVertexPositions(); + int vertexCount = positions.Length / 3; + if (vertexCount == 0) return; + + var white = new Vector4(1f, 1f, 1f, 1f); + var yellow = new Vector4(1f, 0.9f, 0.2f, 1f); + Vector3 right = Vector3.Normalize(Vector3.Cross(Camera.GetForward(), Camera.Up)); + Vector3 up = Vector3.Normalize(Vector3.Cross(right, Camera.GetForward())); + + for (int i = 0; i < vertexCount; i++) + { + var worldPos = new Vector3(positions[i * 3], positions[i * 3 + 1], positions[i * 3 + 2]); + bool isSelected = i == selectedVertexIndex; + float radius = WorldSizeForPixelRadius(worldPos, isSelected ? SelectedVertexPixelRadius : VertexPointPixelRadius); + var colour = isSelected ? yellow : white; + _debugLines.Add((worldPos - right * radius, worldPos + right * radius, colour)); + _debugLines.Add((worldPos - up * radius, worldPos + up * radius, colour)); + } + } + + /// World-space half-size that covers screen pixels at this + /// point's distance, so overlay markers keep a constant on-screen size. + private float WorldSizeForPixelRadius(Vector3 worldPos, float pixelRadius) + { + float distance = Vector3.Distance(Camera.Position, worldPos); + float fovYRad = Camera.Fov * (MathF.PI / 180f); + return 2f * distance * MathF.Tan(fovYRad * 0.5f) * (pixelRadius / Math.Max(1, _viewport.PixelHeight)); + } + protected void OnResize() { - renderTexture.Resize((uint)RenderFrameSize.Width, (uint)RenderFrameSize.Height); - Camera.Resize((uint)RenderFrameSize.Width, (uint)RenderFrameSize.Height); + previewTexWidth = (uint)Math.Max(1, _viewport.PixelWidth); + previewTexHeight = (uint)Math.Max(1, _viewport.PixelHeight); + Camera.Resize(previewTexWidth, previewTexHeight); + try { _vkPreview?.Resize(graphicsDevice, previewTexWidth, previewTexHeight); } + catch (Exception e) { LunaLog.LogError($"[AssetViewer] preview resize failed: {e.Message}"); DisposePreview(); } } + + /// Foliage inspector. Read-only and deliberately raw: every number here is either + /// straight out of the file or one step from it, because foliage is still being reverse + /// engineered and a prettied-up view would hide the two things worth watching - whether the UVs + /// really land on quadrant boundaries, and whether the LOD ranges partition the card set. + private void RenderFoliageList() + { + var level = LunaWindow.Instance.Level; + if (level == null || level.Foliages.Count == 0) + { + ImGui.TextDisabled(LM.Get("GUI_Frame_AssetViewer_NoFoliage")); + return; + } + + foreach (var foliage in level.Foliages) + { + if (!string.IsNullOrWhiteSpace(assetSearch) && + !(foliage.Name ?? "").Contains(assetSearch, StringComparison.OrdinalIgnoreCase)) + continue; + + if (!ImGui.TreeNode($"{foliage.Name}##foliage{foliage.Id}")) continue; + + var meta = foliage.Metadata; + ImGui.Text($"Sprites: {foliage.Sprites.Count} Placements: {foliage.Placements.Count}"); + // TextureIndex is shown raw on purpose - it is 0/1 while the real foliage textures are + // #1286/#1287, and nothing in the files connects them yet (see FoliageMetadata). + ImGui.Text($"foliageId: {meta.FoliageId} textureIndex: {meta.TextureIndex} (unresolved)"); + ImGui.Text($"corner data @0x{meta.SpriteCornerOffset:X} anchor data @0x{meta.SpriteAnchorOffset:X} (vertices.dat 0x9000)"); + + if (ImGui.TreeNode($"Sprite LODs##foliagelod{foliage.Id}")) + { + for (int i = 0; i < meta.SpriteLodRanges.Length; i++) + { + var r = meta.SpriteLodRanges[i]; + if (r.CornerCount <= 0) continue; + ImGui.Text($"LOD {i}: corners [{r.CornerBegin}..{r.CornerEnd}) = {r.SpriteCount} card(s) distance {r.Distance:0.###}"); + } + ImGui.TreePop(); + } + + if (ImGui.TreeNode($"Cards##foliagecards{foliage.Id}")) + { + // Capped: a card set can run to hundreds and every one draws eight numbers. The + // list is for spot-checking the decode, not for browsing all of them. + int shown = 0; + foreach (var card in foliage.Sprites) + { + if (shown++ >= 64) { ImGui.TextDisabled($"... {foliage.Sprites.Count - 64} more"); break; } + ImGui.Text($"[LOD {card.Lod}] anchor ({card.Anchor.X:0.###}, {card.Anchor.Y:0.###}, {card.Anchor.Z:0.###}) packed {card.Packed.Item1:X2} {card.Packed.Item2:X2}"); + for (int k = 0; k < card.CornerOffsets.Length; k++) + ImGui.Text($" corner {k}: offset ({card.CornerOffsets[k].X:0.###}, {card.CornerOffsets[k].Y:0.###}) uv ({card.Uvs[k].X:0.###}, {card.Uvs[k].Y:0.###})"); + ImGui.Separator(); + } + ImGui.TreePop(); + } + + ImGui.TreePop(); + } + } + } diff --git a/ReLunacy/Core/Frames/DockedFrames/EditorSettingsFrame.cs b/ReLunacy/Core/Frames/DockedFrames/EditorSettingsFrame.cs index dee2295..f45da54 100644 --- a/ReLunacy/Core/Frames/DockedFrames/EditorSettingsFrame.cs +++ b/ReLunacy/Core/Frames/DockedFrames/EditorSettingsFrame.cs @@ -51,7 +51,7 @@ protected override void Render(double deltaTime) ImGui.DragFloat(LM.Get("GUI_Frame_EditorSettings_FarClipDist"), ref Program.Settings.RenderDistance, 25, 150, 10000, "%0.1fm"); ImGui.InputInt(LM.Get("GUI_Frame_EditorSettings_MaxFramerate"), ref Program.Settings.TargetFPS); // currentMsaa used to be a local int with no connection to Program.Settings.MSAA_Level - // at all (never initialized from it, never written back to it) — the combo was + // at all (never initialized from it, never written back to it) - the combo was // purely cosmetic and always showed "Disabled" regardless of the real, persisted // setting. Resync from the real value every frame (so external changes, e.g. the // Cancel button's ReloadSettings, are reflected too) and write straight back on edit. @@ -66,7 +66,7 @@ protected override void Render(double deltaTime) ImGui.Checkbox(LM.Get("GUI_Frame_EditorSettings_BackfaceCulling"), ref Program.Settings.BackfaceCulling); ImGui.SameLine(); ImGuiPlus.HelpMarker(LM.Get("GUI_Frame_EditorSettings_BackfaceCullingHelp")); - // Same resync-every-frame pattern as currentMsaa above (see that comment) — + // Same resync-every-frame pattern as currentMsaa above (see that comment) - // applied live by Window.Update via AssetManager.SetTextureFiltering. currentFiltering = (int)Program.Settings.TextureFiltering; if (ImGui.Combo(LM.Get("GUI_Frame_EditorSettings_TextureFiltering"), ref currentFiltering, FilteringOptions, FilteringOptions.Length)) @@ -74,17 +74,9 @@ protected override void Render(double deltaTime) ImGui.Checkbox(LM.Get("GUI_Frame_EditorSettings_EnableLighting"), ref Program.Settings.EnableLighting); ImGui.SameLine(); ImGuiPlus.HelpMarker(LM.Get("GUI_Frame_EditorSettings_EnableLightingHelp")); - if (Program.Settings.EnableLighting) - { - ImGui.Indent(); - ImGui.DragFloat3(LM.Get("GUI_Frame_EditorSettings_LightDirection"), ref Program.Settings.LightDirection, 0.01f, -1f, 1f, "%.2f"); - ImGui.ColorEdit3(LM.Get("GUI_Frame_EditorSettings_LightColor"), ref Program.Settings.LightColor); - ImGui.SliderFloat(LM.Get("GUI_Frame_EditorSettings_LightAmbient"), ref Program.Settings.LightAmbient, 0f, 1f, "%.2f", ImGuiSliderFlags.AlwaysClamp); - // Logarithmic: useful values cluster at the low end (8-64) but sharp - // highlights need room up to 256. - ImGui.SliderFloat(LM.Get("GUI_Frame_EditorSettings_LightSpecularPower"), ref Program.Settings.LightSpecularPower, 1f, 256f, "%.0f", ImGuiSliderFlags.AlwaysClamp | ImGuiSliderFlags.Logarithmic); - ImGui.Unindent(); - } + // Light direction/colour/ambient controls moved to the Level Data frame, which edits + // the level's OWN lighting environment (section 0x8b00) - kept in one place rather + // than split between here and there. if (ImGui.Combo(LM.Get("GUI_Frame_EditorSettings_Language"), ref selectedLanguage, Languages, Languages.Length)) { currLanguage = selectedLanguage; @@ -116,7 +108,7 @@ protected override void Render(double deltaTime) ImGui.DragFloat(LM.Get("GUI_Frame_EditorSettings_GizmosSize"), ref Program.Settings.ToolsGizmoSize, 0, 0, 0, "%.3f", ImGuiSliderFlags.AlwaysClamp); ImGui.Checkbox(LM.Get("GUI_Frame_EditorSettings_GizmoSnapEnabled"), ref Program.Settings.GizmoSnapEnabled); ImGui.InputFloat(LM.Get("GUI_Frame_EditorSettings_GizmoSnapTranslation"), ref Program.Settings.GizmoSnapTranslation, 0.1f, 1.0f, "%.3fm"); - ImGui.InputFloat(LM.Get("GUI_Frame_EditorSettings_GizmoSnapRotation"), ref Program.Settings.GizmoSnapRotation, 1.0f, 15.0f, "%.3f°"); + ImGui.InputFloat(LM.Get("GUI_Frame_EditorSettings_GizmoSnapRotation"), ref Program.Settings.GizmoSnapRotation, 1.0f, 15.0f, "%.3f deg"); ImGui.InputFloat(LM.Get("GUI_Frame_EditorSettings_GizmoSnapScale"), ref Program.Settings.GizmoSnapScale, 0.05f, 0.25f, "%.3f"); ImGui.SliderFloat(LM.Get("GUI_Frame_EditorSettings_VolumeWireThickness"), ref Program.Settings.VolumeWireThickness, 0.01f, 5f, "%.2f", ImGuiSliderFlags.AlwaysClamp); ImGui.SameLine(); @@ -132,7 +124,7 @@ protected override void Render(double deltaTime) ImGui.BeginGroup(); ImGui.DragFloat(LM.Get("GUI_Frame_EditorSettings_CameraSpeed"), ref Program.Settings.CamMoveSpeed, 0.5f, 0.5f, 10000, "%0.2fm/s"); ImGui.DragFloat(LM.Get("GUI_Frame_EditorSettings_CameraShiftSpeed"), ref Program.Settings.CamMaxSpeed, 0.5f, 0.5f, 10000, "%0.2fm/s"); - ImGui.SliderFloat(LM.Get("GUI_Frame_EditorSettings_FOV"), ref Program.Settings.CamFOV, 30f, 120f, "%0.1f°"); + ImGui.SliderFloat(LM.Get("GUI_Frame_EditorSettings_FOV"), ref Program.Settings.CamFOV, 30f, 120f, "%0.1f deg"); ImGui.SliderFloat(LM.Get("GUI_Frame_EditorSettings_Sensitivity"), ref Program.Settings.CamSensivity, 0.001f, 2f, "%0.3f"); ImGui.EndGroup(); ImGui.EndTabItem(); @@ -162,9 +154,6 @@ protected override void Render(double deltaTime) ImGui.BeginGroup(); ImGui.Checkbox("Enable Debug", ref Program.Settings.DebugMode); ImGui.Combo("Logging Level", ref currentLogLevel, ["Debug", "Info", "Warning", "Error", "Fatal"], 5); - ImGui.Checkbox("Legacy Rendering Mode", ref Program.Settings.LegacyRenderingMode); - ImGui.SameLine(); - ImGuiPlus.HelpMarker("If unsure, leave it unchecked. This heavily\naffects performances and has no reason to still be."); ImGui.EndGroup(); ImGui.EndTabItem(); } diff --git a/ReLunacy/Core/Frames/DockedFrames/GameBrowserFrame.cs b/ReLunacy/Core/Frames/DockedFrames/GameBrowserFrame.cs index aab0a57..e981b9d 100644 --- a/ReLunacy/Core/Frames/DockedFrames/GameBrowserFrame.cs +++ b/ReLunacy/Core/Frames/DockedFrames/GameBrowserFrame.cs @@ -1,5 +1,4 @@ using System.Numerics; -using Bliss.CSharp.Interact; using ReLunacy.Core.Frames.Modals; using ReLunacy.Engine.Games; using ReLunacy.Utility; @@ -123,7 +122,7 @@ private void RenderLevelsTab() private void RenderDebugDatTab() { ImGui.TextWrapped("Old-engine levels usually don't ship debug.dat alongside their own " + - "data — it's auto-detected next to the level when possible. Use this if a level loaded " + + "data - it's auto-detected next to the level when possible. Use this if a level loaded " + "without instance/asset names, or to load a different debug.dat than the one that was " + "auto-detected."); ImGui.Separator(); @@ -152,7 +151,7 @@ private void RenderDebugDatTab() { // No level loaded yet: just remember it, it'll be picked up on the next load. LunaWindow.Instance.PendingExternalDebugDatPath = debugDatPathInput; - debugDatStatusMessage = "Saved — it'll be used the next time a level is loaded."; + debugDatStatusMessage = "Saved - it'll be used the next time a level is loaded."; } else { diff --git a/ReLunacy/Core/Frames/DockedFrames/LevelDataFrame.cs b/ReLunacy/Core/Frames/DockedFrames/LevelDataFrame.cs new file mode 100644 index 0000000..234d172 --- /dev/null +++ b/ReLunacy/Core/Frames/DockedFrames/LevelDataFrame.cs @@ -0,0 +1,103 @@ +using System.Numerics; +using ReLunacy.Engine.Loading.Readers; +using ReLunacy.Engine.Scene; +using ReLunacy.Utility; +using ReLunacy.Utility.Localization; + +namespace ReLunacy.Core.Frames.DockedFrames; + +/// Level overview + live lighting controls. Read-only counts (instances, assets, textures, +/// lightmaps...) plus editors for the level's analytic lighting environment (section 0x8b00 - see +/// LightingEnvironmentReader), which edit LevelData.LightingEnvironment in place; View3D re-reads +/// those into the renderer every frame, so changes are live. Falls back to the editor's own sun +/// (EditorSettings) for levels that ship no environment. +public class LevelDataFrame : DockedFrame +{ + protected override ImGuiCond DockingConditions { get; set; } = ImGuiCond.Appearing; + protected override Vector2 DefaultPosition { get; set; } = ImGui.GetWorkCenter(ImGui.GetMainViewport()); + protected override ImGuiWindowFlags WindowFlags { get; set; } = ImGuiWindowFlags.None; + + private const ImGuiColorEditFlags ColourFlags = ImGuiColorEditFlags.Float | ImGuiColorEditFlags.Hdr; + + public LevelDataFrame() : base() + { + FrameName = LM.Get("GUI_Frame_LevelData"); + } + + protected override void Render(double deltaTime) + { + var level = LunaWindow.Instance.Level; + if (level == null) + { + ImGui.TextDisabled(LM.Get("GUI_Frame_LevelData_NoLevel")); + return; + } + + if (ImGui.CollapsingHeader(ImGuiPlus.Label(Icons.Info, LM.Get("GUI_Frame_LevelData_Overview")), ImGuiTreeNodeFlags.DefaultOpen)) + RenderOverview(level); + + if (ImGui.CollapsingHeader(ImGuiPlus.Label(Icons.Lightbulb, LM.Get("GUI_Frame_LevelData_Lighting")), ImGuiTreeNodeFlags.DefaultOpen)) + RenderLighting(level); + } + + private static void Row(string label, string value) + { + ImGui.TableNextRow(); + ImGui.TableSetColumnIndex(0); + ImGui.Text(label); + ImGui.TableSetColumnIndex(1); + ImGui.Text(value); + } + + private static void RenderOverview(LevelData level) + { + var em = EntityManager.Singleton; + if (!ImGui.BeginTable("leveldata_overview", 2, ImGuiTableFlags.SizingStretchProp | ImGuiTableFlags.RowBg | ImGuiTableFlags.Borders)) + return; + + Row(LM.Get("GUI_Frame_LevelData_Engine"), level.IsOldEngine ? "Old" : "New"); + Row(LM.Get("GUI_Frame_LevelData_MobyInstances"), em.MobysCount.ToString()); + Row(LM.Get("GUI_Frame_LevelData_MobyAssets"), level.Mobys.Count.ToString()); + Row(LM.Get("GUI_Frame_LevelData_TieInstances"), em.TiesCount.ToString()); + Row(LM.Get("GUI_Frame_LevelData_TieAssets"), level.Ties.Count.ToString()); + Row(LM.Get("GUI_Frame_LevelData_UFrags"), em.UFragsCount.ToString()); + Row(LM.Get("GUI_Frame_LevelData_Zones"), level.Zones.Count.ToString()); + Row(LM.Get("GUI_Frame_LevelData_Volumes"), em.VolumesCount.ToString()); + Row(LM.Get("GUI_Frame_LevelData_Foliage"), em.Foliage.Count.ToString()); + Row(LM.Get("GUI_Frame_LevelData_Cubemaps"), level.Cubemaps.Count.ToString()); + Row(LM.Get("GUI_Frame_LevelData_Textures"), level.AllTextures.Count.ToString()); + Row(LM.Get("GUI_Frame_LevelData_Shaders"), level.Shaders.Count.ToString()); + Row(LM.Get("GUI_Frame_LevelData_Lightmaps"), level.ZoneLightmaps.Count.ToString()); + Row(LM.Get("GUI_Frame_LevelData_Directionals"), level.ZoneDirectionals.Count.ToString()); + + ImGui.EndTable(); + } + + private static void RenderLighting(LevelData level) + { + var env = level.LightingEnvironment; + if (env == null) + { + ImGui.TextDisabled(LM.Get("GUI_Frame_LevelData_NoLightEnv")); + return; + } + + var ambient = env.Ambient; + if (ImGui.ColorEdit3(LM.Get("GUI_Frame_LevelData_Ambient") + "##amb", ref ambient, ColourFlags)) + env.Ambient = ambient; + + for (int i = 0; i < env.Lights.Count; i++) + { + var light = env.Lights[i]; + ImGui.SeparatorText(LM.Get("GUI_Frame_LevelData_Light", i)); + + var c = light.Colour; + if (ImGui.ColorEdit3(LM.Get("GUI_Frame_LevelData_LightColour") + "##c" + i, ref c, ColourFlags)) + light.Colour = c; + + var d = light.Direction; + if (ImGui.DragFloat3(LM.Get("GUI_Frame_LevelData_LightDir") + "##d" + i, ref d, 0.01f, -1f, 1f)) + light.Direction = d.LengthSquared() > 1e-6f ? Vector3.Normalize(d) : Vector3.UnitY; + } + } +} diff --git a/ReLunacy/Core/Frames/DockedFrames/PSArcExplorer.cs b/ReLunacy/Core/Frames/DockedFrames/PSArcExplorer.cs index a5bd7fc..69ca4b6 100644 --- a/ReLunacy/Core/Frames/DockedFrames/PSArcExplorer.cs +++ b/ReLunacy/Core/Frames/DockedFrames/PSArcExplorer.cs @@ -1,4 +1,3 @@ -using Bliss.CSharp.Interact; using LibreFios; using ReLunacy.Utility; using ReLunacy.Utility.Localization; diff --git a/ReLunacy/Core/Frames/DockedFrames/ProfilerFrame.cs b/ReLunacy/Core/Frames/DockedFrames/ProfilerFrame.cs new file mode 100644 index 0000000..0d9bbbf --- /dev/null +++ b/ReLunacy/Core/Frames/DockedFrames/ProfilerFrame.cs @@ -0,0 +1,120 @@ +using System.Numerics; +using ReLunacy.Engine.Diagnostics; +using ReLunacy.Utility.Localization; + +namespace ReLunacy.Core.Frames.DockedFrames; + +/// Live per-frame CPU breakdown fed by . Answers "where does the +/// frame go?" - command recording, submission, the GPU-idle stall, present - and prints a verdict +/// naming the dominant cost so the next optimisation target is obvious. +/// +/// The numbers are CPU wall-clock: there is no GPU timestamp query in Veldrith, so the GPU's own +/// per-pass time can't be read. The "GPU Wait" phase (the per-frame WaitForIdle) is the stand-in - +/// it is exactly how long the CPU sat blocked for the GPU to finish, which is the honest measure of +/// the GPU tail as long as the frame ends with a full sync. See FrameProfiler's class summary. +public class ProfilerFrame : DockedFrame +{ + protected override ImGuiCond DockingConditions { get; set; } = ImGuiCond.Appearing; + protected override Vector2 DefaultPosition { get; set; } = ImGui.GetWorkCenter(ImGui.GetMainViewport()); + protected override ImGuiWindowFlags WindowFlags { get; set; } = ImGuiWindowFlags.None; + + public ProfilerFrame() : base() + { + FrameName = LM.Get("GUI_Frame_Profiler"); + } + + protected override void Render(double deltaTime) + { + var profiler = FrameProfiler.Singleton; + + double frameMs = profiler.FrameAvgMs; + double fps = frameMs > 0 ? 1000.0 / frameMs : 0; + ImGui.Text(LM.Get("GUI_Frame_Profiler_FrameTotal", frameMs, fps)); + + DrawVerdict(profiler.Verdict()); + + ImGui.Separator(); + + var phases = profiler.Snapshot(); + // Only the root ("Frame") present means no real work has been sampled yet (level not loaded, + // or the first couple of frames). Say so rather than showing a lone 100 % row. + if (phases.Count <= 1) + { + ImGui.TextDisabled(LM.Get("GUI_Frame_Profiler_Collecting")); + return; + } + + if (ImGui.BeginTable("profiler_phases", 4, + ImGuiTableFlags.RowBg | ImGuiTableFlags.BordersInnerH | ImGuiTableFlags.SizingStretchProp)) + { + ImGui.TableSetupColumn(LM.Get("GUI_Frame_Profiler_Phase"), ImGuiTableColumnFlags.WidthStretch, 2f); + ImGui.TableSetupColumn(LM.Get("GUI_Frame_Profiler_Last"), ImGuiTableColumnFlags.WidthStretch, 1f); + ImGui.TableSetupColumn(LM.Get("GUI_Frame_Profiler_Avg"), ImGuiTableColumnFlags.WidthStretch, 1f); + ImGui.TableSetupColumn(LM.Get("GUI_Frame_Profiler_Percent"), ImGuiTableColumnFlags.WidthStretch, 2f); + ImGui.TableHeadersRow(); + + foreach (var p in phases) + { + if (p.Name == FrameProfiler.RootPhase) continue; // the total is already the header line + + ImGui.TableNextRow(); + + ImGui.TableNextColumn(); + // Depth-1 phases are the top-level frame stages; deeper ones (3D Record/Submit) are + // indented so the containment reads at a glance. + if (p.Depth > 1) ImGui.Indent((p.Depth - 1) * 14f); + if (p.Name == FrameProfiler.GpuWaitPhase) + ImGui.TextColored(GpuWaitColour, p.Name); + else + ImGui.Text(p.Name); + if (p.Depth > 1) ImGui.Unindent((p.Depth - 1) * 14f); + + ImGui.TableNextColumn(); + ImGui.Text($"{p.LastMs:0.00}"); + + ImGui.TableNextColumn(); + ImGui.Text($"{p.AvgMs:0.00}"); + + ImGui.TableNextColumn(); + ImGui.ProgressBar((float)(p.Percent / 100.0), new Vector2(-1, 0), $"{p.Percent:0.0}%"); + } + + ImGui.EndTable(); + } + + var counters = profiler.Counters(); + if (counters.Count > 0) + { + ImGui.SeparatorText(LM.Get("GUI_Frame_Profiler_Counters")); + foreach (var (name, value) in counters) + ImGui.Text($"{name}: {value:N0}"); + } + + ImGui.Spacing(); + ImGui.TextDisabled(LM.Get("GUI_Frame_Profiler_GpuNote")); + } + + private static readonly Vector4 GpuWaitColour = new(0.55f, 0.75f, 1f, 1f); + private static readonly Vector4 CpuColour = new(1f, 0.75f, 0.4f, 1f); + private static readonly Vector4 GpuColour = new(0.55f, 0.75f, 1f, 1f); + private static readonly Vector4 PresentColour = new(0.7f, 0.7f, 0.7f, 1f); + + private static void DrawVerdict(FrameProfiler.FrameVerdict verdict) + { + var colour = verdict.Bound switch + { + FrameProfiler.Bound.Cpu => CpuColour, + FrameProfiler.Bound.GpuOrSync => GpuColour, + FrameProfiler.Bound.Present => PresentColour, + _ => new Vector4(0.7f, 0.7f, 0.7f, 1f), + }; + + ImGui.TextColored(colour, verdict.Headline); + if (!string.IsNullOrEmpty(verdict.Detail)) + { + ImGui.PushTextWrapPos(0f); + ImGui.TextDisabled(verdict.Detail); + ImGui.PopTextWrapPos(); + } + } +} diff --git a/ReLunacy/Core/Frames/DockedFrames/PropertyInspectorFrame.cs b/ReLunacy/Core/Frames/DockedFrames/PropertyInspectorFrame.cs index e2c0290..68281be 100644 --- a/ReLunacy/Core/Frames/DockedFrames/PropertyInspectorFrame.cs +++ b/ReLunacy/Core/Frames/DockedFrames/PropertyInspectorFrame.cs @@ -1,6 +1,7 @@ using System.Numerics; -using Bliss.CSharp.Transformations; +using ReLunacy.Engine.Rendering.Resources; using ReLunacy.Core.Selection; +using ReLunacy.Engine.Rendering; using ReLunacy.Engine.Scene; using ReLunacy.Utility; using ReLunacy.Utility.Localization; @@ -18,6 +19,8 @@ public class PropertyInspectorFrame : DockedFrame private Vector3 selectedScale; private Vector3 selectedBSphere; private float selectedBSphereRadius; + private float selectedCullDistance; + private float selectedUpdateDistance; public Entity? SelectedEntity => SelectionManager.Singleton.SelectedEntity; @@ -59,7 +62,7 @@ protected override void Render(double deltaTime) t.Translation = selectedPosition; SelectedEntity.Transform = t; } - if (ImGui.InputFloat3(LM.Get("GUI_Frame_InstanceInspector_Rotation"), ref selectedAngle, "%.1f°")) + if (ImGui.InputFloat3(LM.Get("GUI_Frame_InstanceInspector_Rotation"), ref selectedAngle, "%.1f deg")) { var t = SelectedEntity.Transform; t.Rotation = (selectedAngle * (MathF.PI / 180f)).QuaternionFromEuler(); @@ -68,8 +71,8 @@ protected override void Render(double deltaTime) if (ImGui.InputFloat3(LM.Get("GUI_Frame_InstanceInspector_Scale"), ref selectedScale, "%.3f")) { // EntityVolume keeps its real box size in its own `scale` field rather than - // Transform.Scale (which it always leaves at 1,1,1 — see EntityVolume's constructor - // comment) — writing to Transform.Scale here for a Volume would silently do nothing. + // Transform.Scale (which it always leaves at 1,1,1 - see EntityVolume's constructor + // comment) - writing to Transform.Scale here for a Volume would silently do nothing. if (SelectedEntity is EntityVolume volume) { volume.SetScale(selectedScale); @@ -82,6 +85,11 @@ protected override void Render(double deltaTime) } } + if (v3d != null) + { + ImGui.Text($"{(v3d.Camera.Position - selectedPosition).Length():N03}m away"); + } + ImGui.SeparatorText(LM.Get("GUI_Frame_InstanceInspector_RenderingCategory")); if (ImGui.InputFloat3(LM.Get("GUI_Frame_InstanceInspector_BoundingSpherePos"), ref selectedBSphere, "%.3fm", ImGuiInputTextFlags.ReadOnly)) @@ -96,6 +104,17 @@ protected override void Render(double deltaTime) if (SelectedEntity is EntityMoby moby) { + if (ImGui.InputFloat(LM.Get("GUI_Frame_InstanceInspector_CullDistance"), ref selectedCullDistance, 0, 0, + "%.3f", ImGuiInputTextFlags.ReadOnly)) + { + ((EntityMoby)SelectedEntity).DisplayDistance = selectedCullDistance; + } + if (ImGui.InputFloat(LM.Get("GUI_Frame_InstanceInspector_UpdateDistance"), ref selectedUpdateDistance, 0, 0, + "%.3f", ImGuiInputTextFlags.ReadOnly)) + { + ((EntityMoby)SelectedEntity).UpdateDistance = selectedUpdateDistance; + } + if (ImGui.Button(LM.Get("GUI_Frame_InstanceInspector_OpenInAssetViewer"))) OpenMobyInAssetViewer(moby.BaseMoby.Id); } @@ -106,14 +125,12 @@ protected override void Render(double deltaTime) } else if (SelectedEntity is EntityUFrag ufrag) { - var mat = ufrag.UFrag.Material; - ImGui.Text(LM.Get("GUI_Frame_InstanceInspector_MaterialRenderMode", mat.RenderMode)); - ImGui.Text(LM.Get("GUI_Frame_InstanceInspector_MaterialAlphaClip", mat.AlphaClipThreshold)); - ImGui.Text(LM.Get("GUI_Frame_InstanceInspector_MaterialAlbedoFormat", mat.AlbedoTexture?.Format.ToString() ?? "None")); + if (ImGui.Button(LM.Get("GUI_Frame_InstanceInspector_OpenInAssetViewer"))) + OpenUFragInAssetViewer(ufrag.UFrag.Id); } else if (SelectedEntity is EntityVolume volumeEntity) { - // Volumes carry nothing beyond a transform in the level format itself — old engine has + // Volumes carry nothing beyond a transform in the level format itself - old engine has // no ID/group at all (BaseVolume.Id is just its load-order index there), new engine adds // a TUID + zone group from gp_prius's instance metadata section. This is genuinely all // there is to show; see RegionReader.ReadVolumesOld/New. @@ -127,7 +144,7 @@ protected override void Render(double deltaTime) { // Only the entity's position needs negating to match Camera.Position's convention // (see the distance readout below, which negates Camera.Position the same way to - // compare it against a normal entity-space position) — negating the whole sum, + // compare it against a normal entity-space position) - negating the whole sum, // as this used to, also flipped the pull-back offset, pushing the camera away from // the entity along its forward vector instead of placing it just short of it. v3d.Camera.Position = SelectedEntity.Transform.Translation - v3d.Camera.GetForward() * 10; @@ -167,6 +184,16 @@ private static void OpenTieInAssetViewer(ulong tieId) } } + private static void OpenUFragInAssetViewer(ulong ufragId) + { + var viewer = OpenAssetViewer(); + if (viewer != null) + { + viewer.SelectUFragById(ufragId); + viewer.Focus(); + } + } + private static AssetViewer? OpenAssetViewer() { var viewer = LunaWindow.Instance.GetFirstFrame(); @@ -190,6 +217,8 @@ private void UpdateEntity(Entity? oldSelection, Entity? newSelection) selectedBSphere = Vector3.Zero; selectedPosition = Vector3.Zero; selectedScale = Vector3.Zero; + selectedCullDistance = 0f; + selectedUpdateDistance = 0f; return; } @@ -198,5 +227,10 @@ private void UpdateEntity(Entity? oldSelection, Entity? newSelection) selectedScale = SelectedEntity is EntityVolume volume ? volume.scale : SelectedEntity.Transform.Scale; selectedBSphere = SelectedEntity.BoundingSphere.GetXYZ(); selectedBSphereRadius = SelectedEntity.BoundingSphere.W; + if (SelectedEntity is EntityMoby moby) + { + selectedCullDistance = moby.DisplayDistance; + selectedUpdateDistance = moby.UpdateDistance; + } } } diff --git a/ReLunacy/Core/Frames/DockedFrames/ShaderBrowser.cs b/ReLunacy/Core/Frames/DockedFrames/ShaderBrowser.cs index 081ff68..7326486 100644 --- a/ReLunacy/Core/Frames/DockedFrames/ShaderBrowser.cs +++ b/ReLunacy/Core/Frames/DockedFrames/ShaderBrowser.cs @@ -15,7 +15,7 @@ namespace ReLunacy.Core.Frames.DockedFrames; /// /// Reverse-engineering tool: lists every shader (material) the loaded level parsed, and for the /// selected one shows its raw renderingMode byte, alphaClip, decoded texture references, and a -/// hex dump of every still-unidentified byte range (ShaderMetadataOld/New's Unk fields) — nothing +/// hex dump of every still-unidentified byte range (ShaderMetadataOld/New's Unk fields) - nothing /// here is hidden behind the IMaterial abstraction the renderer uses, since the whole point is to /// see what the file actually contains, not what we've already decided it means. Those ranges are /// deliberately kept as few and as LONG as the known fields allow: an unknown split at a boundary @@ -33,14 +33,21 @@ public class ShaderBrowser : DockedFrame, ILevelListener private ShaderUsageResult? usageResults; // null = no filter (show every render mode). Filtering by the raw byte rather than the - // RenderingMode enum so 0x01/0x02/0x03/etc. — anything not named yet — can still be isolated + // RenderingMode enum so 0x01/0x02/0x03/etc. - anything not named yet - can still be isolated // and inspected, which is the whole point of this for reverse engineering. private byte? renderModeFilter; - // Distinct renderingMode byte values actually present among the loaded shaders, with counts — + // Distinct renderingMode byte values actually present among the loaded shaders, with counts - // rebuilt whenever the shader list changes, not per frame. private readonly List<(byte value, int count)> renderModeCounts = []; - // Off by default (the hex dump alone is the more compact, general-purpose view) — toggled on + // null = no filter; true = only shaders some loaded Moby/Tie/UFrag actually draws; false = only + // the ones nothing draws (foliage/effect/UI/cut-content records). See usedShaderTuids. + private bool? usedFilter; + // TUIDs of every shader reachable from this level's rendered geometry - rebuilt when the shader + // list changes, so the used/unused filter (and the per-row tag) is a set lookup, not a scan. + private readonly HashSet usedShaderTuids = []; + + // Off by default (the hex dump alone is the more compact, general-purpose view) - toggled on // when hunting for a specific numeric value, e.g. a per-material decal-offset bias, across the // still-unidentified Unk byte ranges. private bool showFloatInterpretation; @@ -63,6 +70,22 @@ public void TransmitShaders(LevelData level) .GroupBy(s => (byte)s.RenderingMode) .OrderBy(g => g.Key) .Select(g => (g.Key, g.Count()))); + + // A shader is "used" iff some loaded Moby/Tie/UFrag mesh resolved its material to that TUID. + // Computed once here rather than per row: the Shaders dictionary also carries records no + // rendered geometry references (cut content, effects, and - the reason this filter exists - + // foliage), and telling those apart from the drawn set is exactly what the filter surfaces. + usedShaderTuids.Clear(); + foreach (var moby in level.Mobys.Values) + foreach (var bangle in moby.Bangles) + foreach (var mesh in bangle.Meshes) + usedShaderTuids.Add(mesh.Material.Id); + foreach (var tie in level.Ties.Values) + foreach (var mesh in tie.Meshes) + usedShaderTuids.Add(mesh.Material.Id); + foreach (var zone in level.Zones.Values) + foreach (var ufrag in zone.UFrags) + usedShaderTuids.Add(ufrag.Material.Id); } public void OnLevelLoaded() @@ -87,12 +110,14 @@ public void OnLevelUnloading() { shaders.Clear(); renderModeCounts.Clear(); + usedShaderTuids.Clear(); renderModeFilter = null; + usedFilter = null; selectedShader = -1; usageResults = null; } - // ShaderMetadataOld 0x50/0x54. The new engine's metadata has no identified equivalent — see + // ShaderMetadataOld 0x50/0x54. The new engine's metadata has no identified equivalent - see // MaterialReader.GetParallaxScale, which returns 0 there for the same reason. private static float MetadataParallaxScale(Shader shader) => shader.isOld && shader.metadataOld.HasValue ? shader.metadataOld.Value.parallaxScale : 0f; @@ -100,10 +125,6 @@ private static float MetadataParallaxScale(Shader shader) => private static float MetadataParallaxBias(Shader shader) => shader.isOld && shader.metadataOld.HasValue ? shader.metadataOld.Value.parallaxBias : 0f; - // Old-engine only; new-engine metadata has no identified detail fields (see MaterialReader). - private static float MetadataDetailFloat(Shader shader, Func select) => - shader.isOld && shader.metadataOld.HasValue ? select(shader.metadataOld.Value) : 0f; - // Matches AssetManager's own 0-means-absent fallback, so Reset lands on exactly what a fresh // material build would produce rather than on a literal 0 that collapses the map to one texel. private static float MetadataDetailTiling(Shader shader) @@ -124,10 +145,13 @@ private IEnumerable FilteredShaders() if (renderModeFilter.HasValue) result = result.Where(s => (byte)s.RenderingMode == renderModeFilter.Value); + if (usedFilter.HasValue) + result = result.Where(s => usedShaderTuids.Contains(s.TUID) == usedFilter.Value); + if (!string.IsNullOrWhiteSpace(inputText)) result = result.Where(s => (!string.IsNullOrEmpty(s.name) && s.name.Contains(inputText, StringComparison.OrdinalIgnoreCase)) || - s.TUID.ToString("X").Contains(inputText, StringComparison.OrdinalIgnoreCase)); + s.TUID.ToString().Contains(inputText, StringComparison.OrdinalIgnoreCase)); return result; } @@ -144,12 +168,32 @@ protected override void Render(double deltaTime) foreach (var (value, count) in renderModeCounts) { - if (ImGui.Selectable($"{RenderModeLabel(value)} — {count}##rendermode_{value:X2}", renderModeFilter == value)) + if (ImGui.Selectable($"{RenderModeLabel(value)} - {count}##rendermode_{value:X2}", renderModeFilter == value)) renderModeFilter = value; } ImGui.EndCombo(); } + string usagePreview = usedFilter switch + { + true => LM.Get("GUI_Common_FilterUsed"), + false => LM.Get("GUI_Common_FilterUnused"), + null => LM.Get("GUI_Common_FilterAll"), + }; + if (ImGui.BeginCombo(LM.Get("GUI_Frame_ShaderBrowser_FilterUsage"), usagePreview)) + { + // Counted within the shader list (not usedShaderTuids.Count) so the two rows always add + // up to the total - a used TUID with no matching shader record would otherwise inflate it. + int usedCount = shaders.Count(s => usedShaderTuids.Contains(s.TUID)); + if (ImGui.Selectable(LM.Get("GUI_Common_FilterAll"), usedFilter == null)) + usedFilter = null; + if (ImGui.Selectable($"{LM.Get("GUI_Common_FilterUsed")} - {usedCount}", usedFilter == true)) + usedFilter = true; + if (ImGui.Selectable($"{LM.Get("GUI_Common_FilterUnused")} - {shaders.Count - usedCount}", usedFilter == false)) + usedFilter = false; + ImGui.EndCombo(); + } + var filtered = FilteredShaders().ToList(); if (ImGui.BeginChild("shader_list", new(ImGui.GetContentRegionAvail().X / 3, ImGui.GetContentRegionAvail().Y), ImGuiChildFlags.Borders)) @@ -157,7 +201,7 @@ protected override void Render(double deltaTime) foreach (var shader in filtered) { bool isSelected = selectedShader >= 0 && selectedShader < shaders.Count && ReferenceEquals(shaders[selectedShader], shader); - string label = string.IsNullOrEmpty(shader.name) ? shader.TUID.ToString("X") : shader.name; + string label = string.IsNullOrEmpty(shader.name) ? shader.TUID.ToString() : shader.name; if (ImGui.Selectable($"{label}##shader_{shader.TUID:X}", isSelected)) { selectedShader = shaders.IndexOf(shader); @@ -182,15 +226,18 @@ protected override void Render(double deltaTime) private void DrawShaderDetail(Shader shader) { ImGui.Text(LM.Get("GUI_Frame_ShaderBrowser_Name", string.IsNullOrEmpty(shader.name) ? "-" : shader.name)); - ImGui.Text(LM.Get("GUI_Frame_ShaderBrowser_Tuid", shader.TUID.ToString("X"))); + ImGui.Text(LM.Get("GUI_Frame_ShaderBrowser_Tuid", shader.TUID.ToString())); ImGui.Text(LM.Get("GUI_Frame_ShaderBrowser_Engine", shader.isOld ? "Old" : "New")); ImGui.SeparatorText(LM.Get("GUI_Frame_ShaderBrowser_AlphaSection")); ImGui.Text(LM.Get("GUI_Frame_ShaderBrowser_RenderingMode", RenderModeLabel((byte)shader.RenderingMode))); - float alphaClip = shader.isOld ? shader.metadataOld!.Value.alphaClip : shader.metadataNew!.Value.alphaClip; - ImGui.Text(LM.Get("GUI_Frame_ShaderBrowser_AlphaClip", alphaClip)); + // Old-engine materials have no stored alpha clip: the engine hardcodes the threshold per + // rendering mode (Cutout GEQUAL 128/255, the blended paths 4/255), and the 0x20 float once read + // as "alphaClip" is really an RGB parameter. Only the new engine stores one. + if (!shader.isOld) + ImGui.Text(LM.Get("GUI_Frame_ShaderBrowser_AlphaClip", shader.metadataNew!.Value.alphaClip)); ImGui.SeparatorText(LM.Get("GUI_Frame_ShaderBrowser_TexturesSection")); DrawTextureRef(LM.Get("GUI_Frame_ShaderBrowser_Albedo"), shader.Albedo); @@ -198,12 +245,12 @@ private void DrawShaderDetail(Shader shader) DrawTextureRef(LM.Get("GUI_Frame_ShaderBrowser_Expensive"), shader.Expensive); DrawTextureRef(LM.Get("GUI_Frame_ShaderBrowser_DetailMap"), shader.DetailMap); - // Live per-material parallax scale/bias — a reverse-engineering aid: the game's own shader + // Live per-material parallax scale/bias - a reverse-engineering aid: the game's own shader // computes height * scale + bias from two per-material constants, so these are the two // numbers to hunt for in the raw metadata hex dump below. Type a candidate pair in here // (ctrl+click a drag to enter an exact value) and watch the surface. Deliberately // unclamped and shown at float precision so a value read straight out of the dump can be - // used verbatim — a wide range is the whole point, and the sign is part of what's being + // used verbatim - a wide range is the whole point, and the sign is part of what's being // searched for. Runtime-only, nothing is persisted. Only shown when the material is // actually built (i.e. the loaded region uses it). var assetManager = LunaWindow.Instance.AssetManager; @@ -220,7 +267,7 @@ private void DrawShaderDetail(Shader shader) // parallax fields at all and always report 0/0 (see MaterialReader.GetParallaxScale). ImGui.TextDisabled(LM.Get("GUI_Frame_ShaderBrowser_ParallaxFromFile", MetadataParallaxScale(shader), MetadataParallaxBias(shader))); - // min == max == 0 is ImGui's own spelling for "unbounded" — passing float.MinValue / + // min == max == 0 is ImGui's own spelling for "unbounded" - passing float.MinValue / // float.MaxValue instead overflows the internal (max - min) range calculation to // infinity and leaves the drag inert. Unbounded is deliberate: the sign is part of // what's being searched for, and a candidate straight out of the dump can be any @@ -230,35 +277,22 @@ private void DrawShaderDetail(Shader shader) if (changed) assetManager.SetParallax(shader.TUID, parallaxScale, parallaxBias); - // Back to what the FILE says, not to a hardcoded constant — the point of the sliders is + // Back to what the FILE says, not to a hardcoded constant - the point of the sliders is // to deviate from the parsed value and come back to it. if (ImGui.SmallButton($"{LM.Get("GUI_Common_Reset")}##parallax_reset")) assetManager.SetParallax(shader.TUID, MetadataParallaxScale(shader), MetadataParallaxBias(shader)); - // The game weights each detail channel by its own fragment constant; none of the three - // is located in ShaderMetadata yet, so these start at a neutral 1 and are here to be - // hunted the same way parallax was. Detail only shows up at all where the expensive - // map's alpha (the detail mask) is non-zero. - if (assetManager.TryGetDetailStrengths(shader.TUID, out float detailNormal, out float detailSpec, out float detailTiling)) + // Detail maps are authored to tile above the base map's frequency. Only TILING is exposed: + // the per-channel "detail strengths" this used to offer were reading an unrelated RGB + // parameter triple (proven by the EBOOT reverse), so they've been removed. + if (assetManager.TryGetDetailTiling(shader.TUID, out float detailTiling)) { - bool detailChanged = ImGui.DragFloat(LM.Get("GUI_Frame_ShaderBrowser_DetailNormalStrength"), ref detailNormal, 0.01f, 0f, 0f, "%.4f"); - // Clamped 0..1, unlike the others: this one rides a byte-quantised colour channel - // now that slots 6/7 carry the baked lighting textures. - detailChanged |= ImGui.DragFloat(LM.Get("GUI_Frame_ShaderBrowser_DetailSpecStrength"), ref detailSpec, 0.01f, 0f, 1f, "%.4f"); - // Detail maps are authored to tile above the base map's frequency; the real - // multiplier isn't in the captured fragment shader (it arrives pre-tiled in a - // vertex interpolant), so this is the knob for finding what it should be. - detailChanged |= ImGui.DragFloat(LM.Get("GUI_Frame_ShaderBrowser_DetailTiling"), ref detailTiling, 0.1f, 0f, 0f, "%.3f"); - if (detailChanged) - assetManager.SetDetailStrengths(shader.TUID, detailNormal, detailSpec, detailTiling); - - // Resets to what a fresh material build produces: normal/spec/tiling from the file, - // albedo pinned off (see AssetManager.ForcedDetailAlbedoStrength). + if (ImGui.DragFloat(LM.Get("GUI_Frame_ShaderBrowser_DetailTiling"), ref detailTiling, 0.1f, 0f, 0f, "%.3f")) + assetManager.SetDetailTiling(shader.TUID, detailTiling); + + // Resets to the tiling a fresh material build produces (straight from the file). if (ImGui.SmallButton($"{LM.Get("GUI_Common_Reset")}##detail_reset")) - assetManager.SetDetailStrengths(shader.TUID, - MetadataDetailFloat(shader, static m => m.detailNormalStrength), - MetadataDetailFloat(shader, static m => m.detailSpecStrength), - MetadataDetailTiling(shader)); + assetManager.SetDetailTiling(shader.TUID, MetadataDetailTiling(shader)); } } @@ -272,17 +306,18 @@ private void DrawShaderDetail(Shader shader) // Comparing "Detail" here against whether DetailMap above is actually present, across // a few materials, is what confirms or reverses it. ImGui.Text($"0x10 flags: 0x{meta.flags:X2} (binary {Convert.ToString(meta.flags, 2).PadLeft(8, '0')})"); - ImGui.Text($" Spec:{meta.UsesSpecular} Gloss:{meta.UsesGlossiness} Normal:{meta.UsesNormalMap} Detail:{meta.UsesDetailMap}"); + // Parallax, not specular - see ShaderMetadataOld.UsesParallax. Printed next to + // parallaxScale below so the two can be compared across materials, which is what + // confirms the bit. + ImGui.Text($" Parallax:{meta.UsesParallax} Gloss:{meta.UsesGlossiness} Normal:{meta.UsesNormalMap} Detail:{meta.UsesDetailMap}"); ImGui.Text($"0x12 Class: {meta.Class}"); DrawHexDump("Unk1", 0x13, meta.Unk1); - // Printed as a float as well as hex: this is the candidate slot for the detail-strength - // triple starting one float earlier (0x24/0x28/0x2C instead of 0x28/0x2C/0x30), so it - // needs to be directly comparable against the three below. - DrawHexDump("Unk2a", 0x24, meta.Unk2a); - ImGui.Text($"0x28 detailNormalStrength: {meta.detailNormalStrength:0.######}"); - ImGui.Text($"0x2C detailSpecStrength: {meta.detailSpecStrength:0.######}"); - ImGui.Text($"0x30 detailAlbedoStrength: {meta.detailAlbedoStrength:0.######}"); - DrawHexDump("Unk2b", 0x34, meta.Unk2b); + // values[0].xyz is an RGB parameter triple the engine multiplies by the instance's own RGB + // when the Spatial Lighting flag is set, then uploads as a vertex constant - NOT an alpha + // clip and NOT detail strengths (both of those readings are refuted; see ShaderMetadataOld). + ImGui.Text($"0x20 value0 X: {meta.value0X:0.######} Y: {meta.value0Y:0.######} Z: {meta.value0Z:0.######} W: {meta.value0W:0.######}"); + ImGui.Text($"0x30 value1 X: {meta.value1X:0.######} Y: {meta.value1Y:0.######} (0x34 is known live, meaning unknown)"); + DrawHexDump("Unk2b", 0x38, meta.Unk2b); ImGui.Text($"0x50 parallaxScale: {meta.parallaxScale:0.######}"); ImGui.Text($"0x54 parallaxBias: {meta.parallaxBias:0.######}"); ImGui.Text($"0x58 detailTiling: {meta.detailTiling:0.######}"); @@ -330,7 +365,7 @@ private void DrawShaderDetail(Shader shader) if (usageResults.UFrags.Count > 0) { ImGui.Text(LM.Get("GUI_Frame_TextureExplorer_Preview_UsagesUFrags", usageResults.UFrags.Count)); - // Indexed, not keyed by ufrag.Id — IUFrag.Id is only unique within its own zone (see + // Indexed, not keyed by ufrag.Id - IUFrag.Id is only unique within its own zone (see // TexturesExplorer.SelectUFragInView3D), so two results here can share an Id. for (int i = 0; i < usageResults.UFrags.Count; i++) { @@ -351,13 +386,6 @@ private void DrawTextureRef(string label, Texture? tex) ImGui.Text($"{label}: {(string.IsNullOrEmpty(tex.name) ? tex.id.ToString("X") : tex.name)} (0x{tex.id:X}, {tex.Width}x{tex.Height}, {tex.TexFormat})"); - // See TextureMetadataOld.AlphaKillCandidate — a candidate per-texture alpha bit distinct - // from the shader's own renderingMode byte, cross-referenced from InsomniaToolset but not - // yet confirmed against real data. Surfaced here since it's a texture-level flag, not a - // shader-level one. - if (tex.isOld && tex.textureMetadata is TextureMetadataOld oldMeta) - ImGui.Text(LM.Get("GUI_Frame_ShaderBrowser_AlphaKillCandidate", oldMeta.AlphaKillCandidate)); - var am = LunaWindow.Instance.AssetManager; if (am != null && am.BuiltTextures.TryGetValue(tex.id, out var tex2d)) { @@ -391,7 +419,7 @@ private void DrawHexDump(string label, int baseOffset, byte[]? data) if (!showFloatInterpretation || data.Length < 4) return; - // Every 4-byte-aligned position reinterpreted as a big-endian float32 — this file format + // Every 4-byte-aligned position reinterpreted as a big-endian float32 - this file format // is PS3/PowerPC (big-endian throughout, see StreamHelper.Endianness.Big), so a naive // BitConverter read would silently byte-swap every value. Alignment is to the FILE's // absolute offset, not to the start of this array: several of these ranges begin at an diff --git a/ReLunacy/Core/Frames/DockedFrames/TexturesExplorer.cs b/ReLunacy/Core/Frames/DockedFrames/TexturesExplorer.cs index 084a950..06ac20a 100644 --- a/ReLunacy/Core/Frames/DockedFrames/TexturesExplorer.cs +++ b/ReLunacy/Core/Frames/DockedFrames/TexturesExplorer.cs @@ -1,6 +1,6 @@ -using Bliss.CSharp.Images; -using Bliss.CSharp.Textures; +using ReLunacy.Engine.Rendering.Resources; using ReLunacy.Core.Selection; +using ReLunacy.Engine.Assets.Cubemaps; using ReLunacy.Engine.Assets.Interfaces; using ReLunacy.Engine.Assets.Mobys; using ReLunacy.Engine.Assets.Ties; @@ -15,16 +15,25 @@ namespace ReLunacy.Core.Frames.DockedFrames; public record struct TextureObject { - public TextureObject(ITexture texture, Texture2D tex2d) + public TextureObject(ITexture texture, GpuTexture tex2d, int index) { Texture = texture; + Index = index; TexturePtr = LunaWindow.Instance.imGuiController.GetOrCreateImGuiBinding(LunaWindow.Instance.GraphicsDevice.ResourceFactory, tex2d.DeviceTexture); - BlissTexture = tex2d; + GpuTexture = tex2d; } public readonly string? TextureName => Texture.Name; + + /// Position in the level's texture table, counted in load order. This is the number + /// the file formats reference textures BY - foliage's 0xA200 record, for instance, picks its + /// texture with a small integer, not with a TUID or a pointer - so it stays visible even when a + /// debug name was recovered, since the name is what a human recognises and this is what the + /// data actually says. + public readonly int Index; + public readonly ITexture Texture; - public readonly Texture2D BlissTexture; + public readonly GpuTexture GpuTexture; public readonly ImTextureRef TexturePtr; } @@ -42,14 +51,42 @@ public class TexturesExplorer : DockedFrame, ILevelListener private ImTextureRef selectedTexturePtr; private TextureUsageResult? textureUsageResults; private List? relatedShaders; - // Owned by us (unlike TextureObject.BlissTexture, which AssetManager owns) — built on demand + // Owned by us (unlike TextureObject.GpuTexture, which AssetManager owns): built on demand // when a channel-preview button is clicked, must be disposed before being replaced/dropped. - private Texture2D? channelPreviewTexture; + private GpuTexture? channelPreviewTexture; + + // Environment cubemaps (section 0x5920). Their faces aren't in AssetManager's texture table, so + // the preview textures here are built and owned by this frame (see BuildCubemapFace/RebuildCubemap). + private readonly List cubemapObjects = []; + + // The cubemap's real signal is a shared HDR exponent in the alpha channel, so a plain RGB view + // reads as near-white - HDR exposes rgb * 2^((a-128)/16 * exposure) tonemapped, which is what + // actually shows the environment. The single channels are the raw decoded bytes, grayscale. + private enum CubemapChannel { Hdr, Rgb, R, G, B, A } + + private sealed class CubemapObject(Cubemap cubemap) + { + public readonly Cubemap Cubemap = cubemap; + public float Exposure = 1f; + public CubemapChannel Channel = CubemapChannel.Hdr; + public bool Dirty = true; + public readonly GpuTexture?[] FaceTextures = new GpuTexture?[cubemap.Faces.Count]; + public readonly ImTextureRef[] FacePtrs = new ImTextureRef[cubemap.Faces.Count]; + + public void DisposeFaces() + { + for (int i = 0; i < FaceTextures.Length; i++) + { + FaceTextures[i]?.Dispose(); + FaceTextures[i] = null; + } + } + } private enum UsageFilter { All, Used, Unused } private UsageFilter textureUsageFilter = UsageFilter.All; - // "Used" = referenced by at least one loaded Moby/Tie/UFrag material — same definition + // "Used" = referenced by at least one loaded Moby/Tie/UFrag material - same definition // FindTextureUsages below already answers per-texture on click; computed once per // TransmitTextures call instead of re-scanning every asset for every texture every frame. private HashSet usedTextureIds = []; @@ -67,16 +104,33 @@ public TexturesExplorer() : base() public void TransmitTextures(AssetManager assetManager) { textureObjects.Clear(); + // The counter advances for EVERY source texture, including ones with no built Texture2D to + // show - skipping those would silently renumber everything after them, and the whole point + // of the index is that it matches the position the file formats reference. + int index = 0; foreach (var (id, tex) in assetManager.SourceTextures) { if (assetManager.BuiltTextures.TryGetValue(id, out var tex2d)) - textureObjects.Add(new(tex, tex2d)); + textureObjects.Add(new(tex, tex2d, index)); + index++; } usedTextureIds = ComputeUsedTextureIds(); } - /// textureObjects wraps AssetManager-owned Texture2Ds that are about to be disposed — + private void TransmitCubemaps() + { + foreach (var obj in cubemapObjects) obj.DisposeFaces(); + cubemapObjects.Clear(); + + var level = LunaWindow.Instance.Level; + if (level == null) return; + + foreach (var cubemap in level.Cubemaps) + cubemapObjects.Add(new CubemapObject(cubemap)); // face textures built lazily on first render + } + + /// textureObjects wraps AssetManager-owned Texture2Ds that are about to be disposed - /// drop the reference before that happens rather than leaving a stale/dangling entry showing. public void OnLevelUnloading() { @@ -87,10 +141,13 @@ public void OnLevelUnloading() relatedShaders = null; channelPreviewTexture?.Dispose(); channelPreviewTexture = null; + + foreach (var obj in cubemapObjects) obj.DisposeFaces(); + cubemapObjects.Clear(); } /// Rebuilds selectedTexturePtr as a grayscale view of a single channel of the - /// currently selected texture's decoded RGBA — lets the user visually confirm whether a + /// currently selected texture's decoded RGBA - lets the user visually confirm whether a /// texture actually carries real alpha data instead of guessing from the format alone. private void ShowChannel(TextureObject selection, ReLunacy.Engine.Rendering.TextureUtils.Colours channel) { @@ -98,17 +155,168 @@ private void ShowChannel(TextureObject selection, ReLunacy.Engine.Rendering.Text if (rgba == null) return; byte[] filtered = ReLunacy.Engine.Rendering.TextureUtils.ColourAsMain(rgba, channel); - var image = new Image(width, height, filtered); channelPreviewTexture?.Dispose(); - channelPreviewTexture = new Texture2D(LunaWindow.Instance.GraphicsDevice, image, true); + channelPreviewTexture = new GpuTexture(LunaWindow.Instance.GraphicsDevice, (uint)width, (uint)height, filtered); selectedTexturePtr = LunaWindow.Instance.imGuiController.GetOrCreateImGuiBinding(LunaWindow.Instance.GraphicsDevice.ResourceFactory, channelPreviewTexture.DeviceTexture); } + // Cross cell (row, col) for each face, parallel to Cubemap.FaceNames (+X,-X,+Y,-Y,+Z,-Z) - a + // standard horizontal cross, the same arrangement the RenderDoc reference used. + private static readonly (int Row, int Col)[] CrossCells = + [(1, 2), (1, 0), (0, 1), (2, 1), (1, 1), (1, 3)]; + + private void RenderCubemaps() + { + if (cubemapObjects.Count == 0) return; + if (!ImGui.CollapsingHeader(LM.Get("GUI_Frame_TextureExplorer_Cubemaps", cubemapObjects.Count), ImGuiTreeNodeFlags.DefaultOpen)) + return; + + string[] channelLabels = ["HDR", "RGB", "R", "G", "B", "A"]; + + for (int ci = 0; ci < cubemapObjects.Count; ci++) + { + var obj = cubemapObjects[ci]; + ImGui.PushID(ci); + + for (int i = 0; i < channelLabels.Length; i++) + { + if (i > 0) ImGui.SameLine(); + if (ImGui.RadioButton(channelLabels[i], (int)obj.Channel == i)) + { + obj.Channel = (CubemapChannel)i; + obj.Dirty = true; + } + } + + if (obj.Channel == CubemapChannel.Hdr) + { + float exposure = obj.Exposure; + ImGui.SetNextItemWidth(220); + if (ImGui.SliderFloat(LM.Get("GUI_Frame_TextureExplorer_Cubemap_Exposure"), ref exposure, 0.1f, 4f)) + { + obj.Exposure = exposure; + obj.Dirty = true; + } + } + + if (obj.Dirty) RebuildCubemap(obj); + + for (int f = 0; f < obj.Cubemap.Faces.Count; f++) + { + if (f > 0) ImGui.SameLine(); + ImGui.BeginGroup(); + if (obj.FaceTextures[f] != null) + ImGui.Image(obj.FacePtrs[f], new Vector2(96, 96), Vector2.UnitY, Vector2.UnitX); + else + ImGui.Dummy(new Vector2(96, 96)); + ImGui.Text(Cubemap.FaceNames[f]); + ImGui.EndGroup(); + } + + ImGui.Text(LM.Get("GUI_Frame_TextureExplorer_Cubemap_Info", obj.Cubemap.FaceSize, obj.Cubemap.Faces.Count)); + if (ImGui.Button(LM.Get("GUI_Frame_TextureExplorer_Cubemap_ExportCross"))) + ExportCubemapCross(obj); + + ImGui.PopID(); + ImGui.Separator(); + } + } + + private static void RebuildCubemap(CubemapObject obj) + { + obj.Dirty = false; + var gd = LunaWindow.Instance.GraphicsDevice; + + for (int f = 0; f < obj.Cubemap.Faces.Count; f++) + { + obj.FaceTextures[f]?.Dispose(); + obj.FaceTextures[f] = null; + + byte[]? rgba = BuildCubemapFace(obj.Cubemap.Faces[f], obj.Channel, obj.Exposure, out int w, out int h); + if (rgba == null) continue; + + obj.FaceTextures[f] = new GpuTexture(gd, (uint)w, (uint)h, rgba, mipmap: false); + obj.FacePtrs[f] = LunaWindow.Instance.imGuiController.GetOrCreateImGuiBinding(gd.ResourceFactory, obj.FaceTextures[f]!.DeviceTexture); + } + } + + /// Decodes one cubemap face and applies the current view mode. HDR reconstructs the + /// probe's brightness from the alpha exponent (rgb * 2^((a-128)/16 * exposure)) and tonemaps it; + /// the single-channel modes are the raw decoded bytes shown grayscale, same as the texture + /// channel preview. + private static byte[]? BuildCubemapFace(ITexture face, CubemapChannel channel, float exposure, out int w, out int h) + { + byte[]? rgba = ReLunacy.Engine.Rendering.TextureUtils.DecodeToRgba8888(face, out w, out h); + if (rgba == null) return null; + + var result = new byte[rgba.Length]; + for (int i = 0; i < rgba.Length; i += 4) + { + byte r = rgba[i], g = rgba[i + 1], b = rgba[i + 2], a = rgba[i + 3]; + switch (channel) + { + case CubemapChannel.Hdr: + float e = MathF.Pow(2f, (a - 128) / 16f * exposure); + result[i + 0] = Tonemap(r / 255f * e); + result[i + 1] = Tonemap(g / 255f * e); + result[i + 2] = Tonemap(b / 255f * e); + result[i + 3] = 255; + break; + case CubemapChannel.Rgb: + result[i + 0] = r; result[i + 1] = g; result[i + 2] = b; result[i + 3] = 255; + break; + default: + byte v = channel switch + { + CubemapChannel.R => r, + CubemapChannel.G => g, + CubemapChannel.B => b, + _ => a, + }; + result[i + 0] = v; result[i + 1] = v; result[i + 2] = v; result[i + 3] = 255; + break; + } + } + + return result; + } + + // Reinhard tonemap + gamma, so HDR values above 1 roll off instead of clipping flat white. + private static byte Tonemap(float c) + { + c = c / (1f + c); + return (byte)(Math.Clamp(MathF.Pow(c, 1f / 2.2f), 0f, 1f) * 255f); + } + + private static void ExportCubemapCross(CubemapObject obj) + { + int fs = obj.Cubemap.FaceSize; + var cross = new byte[4 * fs * 3 * fs * 4]; // 4x3 grid of faces, RGBA, transparent by default + + for (int f = 0; f < obj.Cubemap.Faces.Count; f++) + { + byte[]? face = BuildCubemapFace(obj.Cubemap.Faces[f], obj.Channel, obj.Exposure, out int w, out int h); + if (face == null || w != fs || h != fs) continue; + + var (row, col) = CrossCells[f]; + for (int y = 0; y < fs; y++) + { + int srcRow = y * fs * 4; + int dstRow = ((row * fs + y) * (4 * fs) + col * fs) * 4; + Array.Copy(face, srcRow, cross, dstRow, fs * 4); + } + } + + var path = Path.Combine(Program.EditorPath, "Extracted"); + if (!Directory.Exists(path)) Directory.CreateDirectory(path); + new Image(4 * fs, 3 * fs, cross).SaveAsPng(Path.Combine(path, $"Cubemap_{obj.Cubemap.Id:X}_cross.png")); + } + /// Same "referenced by a loaded Moby/Tie/UFrag material" definition as /// MaterialUsesTexture/FindTextureUsages below, just collected in one pass over every asset - /// instead of one scan per texture — building this once for potentially thousands of textures - /// the way FindTextureUsages does per-click would be O(textures × assets). + /// instead of one scan per texture - building this once for potentially thousands of textures + /// the way FindTextureUsages does per-click would be O(textures x assets). private static HashSet ComputeUsedTextureIds() { var used = new HashSet(); @@ -142,6 +350,7 @@ public void OnLevelLoaded() { if (LunaWindow.Instance.AssetManager != null) TransmitTextures(LunaWindow.Instance.AssetManager); + TransmitCubemaps(); } /// Selects the texture with the given asset id, e.g. when jumping here from another frame. Returns false if it isn't in the currently transmitted set. @@ -161,7 +370,7 @@ public bool SelectTexture(ulong textureId) // Texture names come straight from the game's own string tables, which for some formats // (e.g. new-engine shader-referenced texture names) are full slash-delimited asset paths, - // not bare filenames — writing that as-is into Path.Combine either creates unwanted nested + // not bare filenames - writing that as-is into Path.Combine either creates unwanted nested // directories under Extracted/ or fails outright. Keep only the last path segment, and fall // back to the texture's index (not e.g. "unnamed") when it has no name at all. private static string GetExportFileName(string? textureName, int index) => @@ -185,7 +394,7 @@ private static TextureUsageResult FindTextureUsages(ulong textureId) var mobys = level.Mobys.Values.Where(m => MobyUsesTexture(m, textureId)).ToList(); var ties = level.Ties.Values.Where(t => TieUsesTexture(t, textureId)).ToList(); - // UFrags carry a single Material directly (no per-mesh loop — a UFrag is one mesh). + // UFrags carry a single Material directly (no per-mesh loop - a UFrag is one mesh). var ufrags = level.Zones.Values .SelectMany(z => z.UFrags) .Where(u => MaterialUsesTexture(u.Material, textureId)) @@ -194,7 +403,7 @@ private static TextureUsageResult FindTextureUsages(ulong textureId) return new TextureUsageResult(mobys, ties, ufrags); } - // Raw shaders, not materials — a texture can be referenced by a shader that isn't actually + // Raw shaders, not materials - a texture can be referenced by a shader that isn't actually // used by any loaded mesh (cut content), which FindTextureUsages above wouldn't find at all // since it only walks placed Mobys/Ties/UFrags. Level.Shaders carries every shader the loader // parsed regardless of whether it's reachable from loaded geometry (see LevelData.Shaders). @@ -257,10 +466,10 @@ private static void OpenTieInAssetViewer(ulong tieId) return viewer; } - // UFrags are baked per-zone terrain, not a browsable asset catalog like Mobys/Ties — the + // UFrags are baked per-zone terrain, not a browsable asset catalog like Mobys/Ties - the // coherent selection target for one is the scene entity already loaded in the 3D view. // Matched by reference, not Id: IUFrag.Id is only unique within its own zone (ZoneReader - // assigns it as a local loop index), so two UFrags from different zones can share an Id — + // assigns it as a local loop index), so two UFrags from different zones can share an Id - // EntityUFrag.UFrag holds the exact same IUFrag instance from LevelData.Zones though, so // reference equality is the one comparison that's actually unambiguous here. private static void SelectUFragInView3D(IUFrag ufrag) @@ -282,13 +491,21 @@ private IEnumerable FilteredTextureObjects() _ => textureObjects, }; - return string.IsNullOrWhiteSpace(inputText) - ? objects - : objects.Where(t => (t.TextureName ?? "").Contains(inputText, StringComparison.OrdinalIgnoreCase)); + if (string.IsNullOrWhiteSpace(inputText)) return objects; + + // A bare number matches the index exactly, so typing "1" finds texture #1 rather than + // every name containing a 1 - that's the only way to look a texture up when all you have + // is the number some other structure referenced it by. Anything else searches names. + if (int.TryParse(inputText.Trim(), out int wantedIndex)) + return objects.Where(t => t.Index == wantedIndex); + + return objects.Where(t => (t.TextureName ?? "").Contains(inputText, StringComparison.OrdinalIgnoreCase)); } protected override void Render(double deltaTime) { + RenderCubemaps(); + ImGui.InputTextWithHint(LM.Get("GUI_Frame_TextureExplorer_SearchLabel"), LM.Get("GUI_Frame_TextureExplorer_SearchHint", textureObjects.Count), ref inputText, 128); int filter = (int)textureUsageFilter; @@ -315,7 +532,7 @@ protected override void Render(double deltaTime) ImGui.Image(texobj.TexturePtr, new(128, 128), Vector2.UnitY, Vector2.UnitX); if (ImGui.IsItemClicked()) { - // Index into the FULL textureObjects list, not filteredObjects — the + // Index into the FULL textureObjects list, not filteredObjects - the // preview panel below indexes textureObjects[selectedTexture] directly, and // filtering/searching can reorder or drop entries relative to it. selectedTexture = textureObjects.FindIndex(t => t.Texture.Id == texobj.Texture.Id); @@ -328,7 +545,15 @@ protected override void Render(double deltaTime) bool isUsed = usedTextureIds.Contains(texobj.Texture.Id); if (!isUsed) ImGui.PushStyleColor(ImGuiCol.Text, ImGui.GetStyle().Colors[(int)ImGuiCol.TextDisabled]); - ImGui.Text(texobj.TextureName ?? $"Tex_{i}"); + // texobj.Index, never the loop counter: `i` walks the FILTERED list, so with a + // search or usage filter active it labelled textures with whatever position + // they happened to land on that frame. + ImGui.Text($"#{texobj.Index}"); + if (texobj.TextureName is { Length: > 0 } name) + { + ImGui.SameLine(); + ImGui.TextWrapped(name.Split('/')[^1]); + } if (!isUsed) ImGui.PopStyleColor(); ImGui.NextColumn(); @@ -341,7 +566,7 @@ protected override void Render(double deltaTime) ImGui.SameLine(); // AlwaysVerticalScrollbar: without it, the scrollbar's appearance depends on whether // the Find Usages results (a variable-length list) push content past the visible - // height — but the image above is sized from ContentRegionAvail().X, so the + // height - but the image above is sized from ContentRegionAvail().X, so the // scrollbar showing up shrinks the available width, which shrinks the square image, // which shrinks total content height, which removes the need for a scrollbar next // frame, which grows the image back... an every-frame oscillation. Reserving the @@ -386,6 +611,7 @@ protected override void Render(double deltaTime) } ImGui.Separator(); ImGui.BeginGroup(); + ImGui.Text(LM.Get("GUI_Frame_TextureExplorer_Preview_TextureIndex")); ImGui.Text(LM.Get("GUI_Frame_TextureExplorer_Preview_TextureName")); ImGui.Text(LM.Get("GUI_Frame_TextureExplorer_Preview_TextureCompressionType")); ImGui.Text(LM.Get("GUI_Frame_TextureExplorer_Preview_TextureDimensions")); @@ -393,10 +619,14 @@ protected override void Render(double deltaTime) ImGui.EndGroup(); ImGui.SameLine(); ImGui.BeginGroup(); - ImGui.Text(selection.TextureName ?? $"Tex_{selectedTexture}"); + // Both numbers, because they answer different questions: Index is the position the + // file formats reference a texture by, Id is where its metadata record physically + // sits (the old engine uses the record's own offset in main.dat as its id). + ImGui.Text($"{selection.Index} (id 0x{selection.Texture.Id:X})"); + ImGui.Text(selection.TextureName is { Length: > 0 } n ? n : $"Tex_{selection.Index}"); ImGui.Text(selection.Texture.Format.ToString()); ImGui.Text($"{selection.Texture.Width}x{selection.Texture.Height}"); - ImGui.Text($"{selection.BlissTexture.Images[0].Data.Length / 1000f}KB"); + ImGui.Text($"{selection.Texture.Width * selection.Texture.Height * 4 / 1000f}KB"); ImGui.EndGroup(); if(ImGui.Button(LM.Get("GUI_Frame_TextureExplorer_Preview_ExportRaw"))) { @@ -404,7 +634,7 @@ protected override void Render(double deltaTime) if (!Directory.Exists(path)) Directory.CreateDirectory(path); - File.WriteAllBytes(Path.Combine(path, GetExportFileName(selection.TextureName, selectedTexture) + ".raw"), selection.Texture.GetPixelData()); + File.WriteAllBytes(Path.Combine(path, GetExportFileName(selection.TextureName, selection.Index) + ".raw"), selection.Texture.GetPixelData()); } ImGui.SameLine(); if(ImGui.Button(LM.Get("GUI_Frame_TextureExplorer_Preview_ExportPNG"))) @@ -413,8 +643,12 @@ protected override void Render(double deltaTime) if (!Directory.Exists(path)) Directory.CreateDirectory(path); - var clone = (Image)selection.BlissTexture.Images[0].Clone(); - clone.SaveAsPng(Path.Combine(path, GetExportFileName(selection.TextureName, selectedTexture) + ".png")); + // Re-decoded from the source texture rather than read back off the GPU one: the + // GPU copy has a mip chain and no CPU-side pixels, and this is the same decode + // every other view in this frame does. + byte[]? pixels = ReLunacy.Engine.Rendering.TextureUtils.DecodeToRgba8888(selection.Texture, out int pw, out int ph); + if (pixels != null) + new Image(pw, ph, pixels).SaveAsPng(Path.Combine(path, GetExportFileName(selection.TextureName, selection.Index) + ".png")); } ImGui.Separator(); if (ImGui.Button(LM.Get("GUI_Frame_TextureExplorer_Preview_FindUsages"))) @@ -451,7 +685,7 @@ protected override void Render(double deltaTime) ImGui.Text(LM.Get("GUI_Frame_TextureExplorer_Preview_UsagesUFrags", textureUsageResults.UFrags.Count)); // Indexed, not keyed by ufrag.Id: IUFrag.Id is only unique within its // own zone (see SelectUFragInView3D), so two results here can share - // an Id — using the list index keeps these ImGui ids unique instead. + // an Id - using the list index keeps these ImGui ids unique instead. for (int i = 0; i < textureUsageResults.UFrags.Count; i++) { var ufrag = textureUsageResults.UFrags[i]; diff --git a/ReLunacy/Core/Frames/DockedFrames/View3D.cs b/ReLunacy/Core/Frames/DockedFrames/View3D.cs index 21325e1..2263974 100644 --- a/ReLunacy/Core/Frames/DockedFrames/View3D.cs +++ b/ReLunacy/Core/Frames/DockedFrames/View3D.cs @@ -1,15 +1,8 @@ -using System.Drawing; using System.Numerics; -using Bliss.CSharp.Camera.Dim3; -using Bliss.CSharp.Graphics.Rendering.Renderers; -using Bliss.CSharp.Graphics.Rendering.Renderers.Forward; -using Bliss.CSharp.Interact; -using Bliss.CSharp.Interact.Keyboards; -using Bliss.CSharp.Interact.Mice; -using Bliss.CSharp.Textures; using ReLunacy.Core.Selection; using ReLunacy.Engine.Rendering; using ReLunacy.Engine.Scene; +using ReLunacy.Engine.Diagnostics; using ReLunacy.Utility; using ReLunacy.Utility.Localization; using Veldrith; @@ -23,30 +16,40 @@ public class View3D : DockedFrame protected override ImGuiWindowFlags WindowFlags { get; set; } = ImGuiWindowFlags.NoScrollbar; private readonly GraphicsDevice graphicsDevice; - private readonly CommandList commandList; - private readonly DecalAwareForwardRenderer renderer; + private readonly SceneLighting lighting = new(); - // Live lightmap research controls, driven by the UFrag Inspector. They live on the renderer - // (it owns the LightBuffer) and are surfaced here because View3D is what holds the renderer. + // Live lightmap research controls, driven by the UFrag Inspector. They live on SceneLighting and + // are surfaced here because View3D is what owns it. // See LightData for what each one stands in for; none of them is a game value. - public Vector2 LightmapUVScale { get => renderer.LightmapUVScale; set => renderer.LightmapUVScale = value; } - public Vector2 LightmapUVOffset { get => renderer.LightmapUVOffset; set => renderer.LightmapUVOffset = value; } - public float BakedLightScale { get => renderer.BakedLightScale; set => renderer.BakedLightScale = value; } - public float BakedBumpFade { get => renderer.BakedBumpFade; set => renderer.BakedBumpFade = value; } - public bool BakedDebugView { get => renderer.BakedDebugView; set => renderer.BakedDebugView = value; } - public Vector2 LightmapUVPivot { get => renderer.LightmapUVPivot; set => renderer.LightmapUVPivot = value; } - public float LightmapUVRotation { get => renderer.LightmapUVRotation; set => renderer.LightmapUVRotation = value; } - public Cam3D Camera { get; private set; } - private RenderTexture2D renderTexture; - private readonly ImmediateRenderer immediateRenderer; - private readonly PickingRenderer pickingRenderer; - private readonly SelectionOutlineRenderer selectionOutlineRenderer; - public Rectangle FrameContentRegion { get; private set; } - public Vector2 FramePos { get; private set; } - public Vector2 MousePos { get; private set; } - - public MouseGrabHandler rmbghandler { get; } = new() { mouseButton = MouseButton.Right }; + public Vector2 LightmapUVScale { get => lighting.LightmapUVScale; set => lighting.LightmapUVScale = value; } + public Vector2 LightmapUVOffset { get => lighting.LightmapUVOffset; set => lighting.LightmapUVOffset = value; } + public float BakedLightScale { get => lighting.BakedLightScale; set => lighting.BakedLightScale = value; } + public float BakedBumpFade { get => lighting.BakedBumpFade; set => lighting.BakedBumpFade = value; } + public float BakedAmbient { get => lighting.BakedAmbient; set => lighting.BakedAmbient = value; } + public bool BakedDebugView { get => lighting.BakedDebugView; set => lighting.BakedDebugView = value; } + // Cubemap reflection: strength of the (normally near-invisible) reflection term, and a debug + // view that shows the raw reflection on everything. See LitModelShaderSource's ENVIRONMENT FILL. + public float ReflectionIntensity { get; set; } = 0.12f; + public bool ReflectionDebugView { get => lighting.ReflectionDebugView; set => lighting.ReflectionDebugView = value; } + public float ReflectionBase { get => lighting.ReflectionBase; set => lighting.ReflectionBase = value; } + public Vector2 LightmapUVPivot { get => lighting.LightmapUVPivot; set => lighting.LightmapUVPivot = value; } + public float LightmapUVRotation { get => lighting.LightmapUVRotation; set => lighting.LightmapUVRotation = value; } + public EditorCamera Camera { get; private set; } + // Panel size in pixels, tracked directly instead of through a Bliss render texture: the scene is + // rendered by the raw-Vulkan renderer into its own display texture, so there is no Bliss target + // left for this view to own. + private uint viewWidth = 300, viewHeight = 300; + + // The viewport image, its toolbar, the gizmo, and the rules for which of them gets a click. + private readonly Viewport3D _viewport = new(); + + /// Where the rendered image sits on screen, for anything that has to line up with it from + /// outside the frame (the stats overlay positions itself against these). + public Vector2 ViewportScreenPos => _viewport.ScreenPos; + public Vector2 ViewportSize => _viewport.Size; + + private readonly MouseGrabHandler rmbghandler = new() { mouseButton = MouseButton.Right }; public Entity? SelectedEntity { @@ -59,128 +62,300 @@ public Entity? SelectedEntity public View3D(GraphicsDevice gd) { FrameName = LM.Get("GUI_Frame_View3D"); - Camera = new Cam3D( - gd, + Camera = new EditorCamera( Vector3.Zero, Vector3.UnitZ, - 1f, Vector3.UnitY, - ProjectionType.Perspective, - CameraMode.Custom, Program.Settings.CamFOV, 0.01f, Program.Settings.RenderDistance); - renderer = new DecalAwareForwardRenderer(gd); graphicsDevice = gd; - commandList = graphicsDevice.ResourceFactory.CreateCommandList(); - immediateRenderer = new ImmediateRenderer(gd); - pickingRenderer = new PickingRenderer(gd); - selectionOutlineRenderer = new SelectionOutlineRenderer(gd); + } + + // The scene renderer now lives on AssetManager (see its SceneRenderer property) so closing and + // reopening this panel does not force re-uploading the whole level's geometry/textures - only this + // panel's own state (camera, gizmo, viewport size) was ever View3D-specific. This view still owns + // DRIVING it every frame (Frame/SubmitFrame/Pick/Resize below), so the renderer is never touched + // while the panel is closed - only its GPU-resident state outlives the panel now, not its activity. + private Engine.Rendering.Vulkan.VulkanRenderer? VkStage => Core.LunaWindow.Instance.AssetManager?.SceneRenderer; + + /// Hands the recorded scene to the GPU. Called by the host AFTER the swapchain present, so + /// the GPU works through it while the next frame is being pumped, updated and recorded. Submitting + /// it inside Render would put it before the host's device-wide wait, which would drain it again + /// immediately and leave nothing overlapping. + public void SubmitScene() + { + var vkStage = VkStage; + if (vkStage == null) return; + try { vkStage.SubmitFrame(); } + catch (Exception e) { LunaLog.LogError($"[VkRenderer] submit failed: {e.Message}"); Core.LunaWindow.Instance.AssetManager?.InvalidateSceneRenderer(); } + } + + /// The Render menu's per-type toggles, as the mask the renderer culls with. Volumes are not + /// in here: they are not part of the recorded scene, and BuildVolumeList already skips them. + private static Engine.Rendering.Vulkan.SceneEntityKind VisibleEntityKinds() + { + var em = EntityManager.Singleton; + var kinds = Engine.Rendering.Vulkan.SceneEntityKind.Other; + if (em.renderMobys) kinds |= Engine.Rendering.Vulkan.SceneEntityKind.Moby; + if (em.renderTies) kinds |= Engine.Rendering.Vulkan.SceneEntityKind.Tie; + if (em.renderUFrags) kinds |= Engine.Rendering.Vulkan.SceneEntityKind.UFrag; + if (em.renderFoliage) kinds |= Engine.Rendering.Vulkan.SceneEntityKind.Foliage; + return kinds; + } + + // Bounding-sphere debug overlay. The line set is STATIC (the spheres do not move with the camera), + // so it is built once when the toggle flips rather than every frame: metropolis is ~10k entities, + // which at three rings apiece is a third of a million line segments to write out. + private bool _appliedBoundingSpheres; + private readonly List<(Vector3 a, Vector3 b, Vector4 color)> _sphereLines = new(); + + private const int SphereRingSegments = 16; + private static readonly Vector4 BoundingSphereColour = new(0.25f, 0.9f, 1f, 0.55f); + + /// Pushes (or clears) the Render menu's bounding-sphere wireframes. Draws the same spheres + /// the renderer culls against, so it doubles as a way to SEE the culling: anything whose sphere does + /// not contain it will pop at the screen edge. + private void UpdateBoundingSphereOverlay() + { + bool wanted = EntityManager.Singleton.renderBoundingSpheres; + if (wanted == _appliedBoundingSpheres) return; + _appliedBoundingSpheres = wanted; + + _sphereLines.Clear(); + if (wanted) + { + foreach (var entity in EntityManager.Singleton.AllEntities()) + { + var sphere = entity.WorldBoundingSphere; + if (sphere.W <= 0f) continue; + var centre = new Vector3(sphere.X, sphere.Y, sphere.Z); + // Three axis-aligned rings. Enough to read a sphere's size and position at a glance + // without the cost of a real wireframe sphere. + AppendRing(centre, sphere.W, Vector3.UnitX, Vector3.UnitY); + AppendRing(centre, sphere.W, Vector3.UnitY, Vector3.UnitZ); + AppendRing(centre, sphere.W, Vector3.UnitZ, Vector3.UnitX); + } + } - renderTexture = new RenderTexture2D(gd, 300u, 300u, true, (TextureSampleCount)Program.Settings.MSAA_Level); + try { VkStage?.SetDebugLines(_sphereLines); } + catch (Exception e) { LunaLog.LogError($"[VkRenderer] debug lines failed: {e.Message}"); } + } + + private void AppendRing(Vector3 centre, float radius, Vector3 u, Vector3 v) + { + Vector3 previous = centre + u * radius; + for (int i = 1; i <= SphereRingSegments; i++) + { + float angle = i / (float)SphereRingSegments * MathF.Tau; + Vector3 next = centre + (u * MathF.Cos(angle) + v * MathF.Sin(angle)) * radius; + _sphereLines.Add((previous, next, BoundingSphereColour)); + previous = next; + } + } + + private bool showClipControls; + + /// Toolbar over the 3D viewport, same component the asset preview uses. Scoped to + /// controls that describe THIS viewport; anything scene-wide belongs in the settings frame. + /// The viewport opened the overlay when it drew the image, so this only adds to it. + private void DrawViewportOverlay() + { + var overlay = _viewport.Overlay; + overlay.ToggleButton("C", ref showClipControls, LM.Get("GUI_Frame_AssetViewer_ClipControls")); + + if (showClipControls && overlay.BeginPanel("clip", new Vector2(280f, 0f))) + { + float farPlane = Program.Settings.RenderDistance; + ImGui.SetNextItemWidth(-1f); + if (ImGui.SliderFloat("##far", ref farPlane, 1f, 100000f, LM.Get("GUI_Frame_AssetViewer_FarClip"), ImGuiSliderFlags.Logarithmic)) + Program.Settings.RenderDistance = farPlane; + overlay.EndPanel(); + } + } + + // BuildVkScene moved to AssetManager (see AssetManager.TryCaptureScene) - everything it read + // (VulkanSceneCapture, EntityManager.Singleton, AssetManager itself) was already level-scoped, not + // View3D-specific, which is what let the captured scene's lifetime move with it. + + // Reused per-frame list of (edge world matrix, colour) for the trigger volumes' wireframe edges (12 + // per volume), handed to the VK renderer to draw as depth-tested thin-box edges - same geometry the + // pick target uses. Rebuilt every frame so selection colour, edits and the Render>Volumes toggle all + // take effect immediately without touching the static scene capture. + private readonly List<(System.Numerics.Matrix4x4 world, System.Numerics.Vector4 color, uint pickId)> _vkVolumes = new(); + private List<(System.Numerics.Matrix4x4 world, System.Numerics.Vector4 color, uint pickId)> BuildVolumeList() + { + _vkVolumes.Clear(); + var em = Engine.Scene.EntityManager.Singleton; + if (em.renderVolumes) + foreach (var region in em.Regions) + foreach (var e in region.Volumes.Entities) + if (e is Engine.Scene.EntityVolume v && v.allowRender) + { + var color = v.VolumeColour; + uint pickId = (uint)v.ID; + foreach (var edge in v.GetWorldEdgeTransforms()) + _vkVolumes.Add((edge, color, pickId)); + } + return _vkVolumes; } protected override void Render(double deltaTime) { - // Cam3D.Fov/FarPlane are public fields only ever set by View3D's own constructor, so a - // change made in the Editor Settings frame afterwards would otherwise never reach the - // already-constructed Camera without restarting the app. Cam3D.Begin (called below every - // frame) already recomputes its projection matrix from these fields each call, so simply - // keeping them in sync here is enough — no separate recompute needed. + // Fov/FarPlane are public fields set at construction, so a change made afterwards (the + // settings frame, or the viewport overlay's clip slider) would never reach the already-built + // camera without this. Camera.Update recomputes the projection from them every frame, so + // keeping them in sync here is enough - no separate recompute needed. Camera.Fov = Program.Settings.CamFOV; Camera.FarPlane = Program.Settings.RenderDistance; - // EntityManager (ReLunacy.Engine) has no reference to Program.Settings (app-layer) — see + // EntityManager (ReLunacy.Engine) has no reference to Program.Settings (app-layer) - see // EntityManager.VolumeWireThickness's own comment. EntityManager.Singleton.VolumeWireThickness = Program.Settings.VolumeWireThickness; EntityManager.Singleton.VolumeColor = Program.Settings.VolumeColor; EntityManager.Singleton.VolumeSelectedColor = Program.Settings.VolumeSelectedColor; - renderer.LightDirection = Program.Settings.LightDirection; - renderer.LightColor = Program.Settings.LightColor; - renderer.Ambient = Program.Settings.LightAmbient; - renderer.SpecularPower = Program.Settings.LightSpecularPower; - // Flat stand-in for the level's environment cubemap (see LevelData.EnvironmentAverage). // The game's cubemap reflection is additive and independent of the lightmap, which is what // keeps its baked shadows off pure black; without it ours fall to exactly albedo * 0. // Intensity stays 0 when the level has no cubemap, so nothing changes for those. - // Intensity is deliberately far below 1: the averaged colour folds the cubemap's alpha in - // as a LINEAR weight, but in the game alpha is an HDR EXPONENT (rgb * exp2(a*scale+bias)) - // whose constants we can't source — treating a mid alpha as "half strength" instead of the - // small exp2 result it really encodes overestimates the fill several times over. At 1.0 - // that added ~0.45 linear (~0.7 after gamma) to every specular surface, washing the whole - // scene to desaturated white (confirmed live). This is the knob to tune against the real - // game; the right long-term fix is decoding the exponent, not raising this. + // Intensity is deliberately far below 1: the cubemap decode can produce HDR values, and at + // full strength the additive term washes the scene out quickly. This is the remaining knob + // for matching the game's final exposure/specular scale. var env = Core.LunaWindow.Instance.Level?.EnvironmentAverage; - renderer.EnvironmentColour = env ?? Vector3.One; - renderer.EnvironmentIntensity = env.HasValue ? 0.12f : 0f; + lighting.EnvironmentColour = env ?? Vector3.One; + lighting.EnvironmentIntensity = env.HasValue ? ReflectionIntensity : 0f; + // The real cubemap the lit shader samples for reflections, in place of the flat average + // above. AssetManager always provides one (a 1x1 fallback when the level has none), so the + // lit effect's set 10 is always bound; EnvironmentIntensity being 0 above is what keeps a + // fallback from contributing. See AssetManager.BuildEnvironmentCubemap. + lighting.EnvironmentCubemap = Core.LunaWindow.Instance.AssetManager?.EnvironmentCubemapView; + + // The game's own analytic lighting (section 0x8b00) for non-baked surfaces, in place of the + // fabricated editor sun. Null on levels without one, in which case the shader keeps the flat + // ambient fallback. See LightingEnvironmentReader / LitModelShaderSource. + var lightEnv = Core.LunaWindow.Instance.Level?.LightingEnvironment; + lighting.HasLightingEnvironment = lightEnv != null; + if (lightEnv != null) + { + // Map the variable-length light list into the shader's two fixed slots; an absent light + // gets a zero colour so it contributes nothing (see LitModelShaderSource). + lighting.EnvAmbient = lightEnv.Ambient; + var lights = lightEnv.Lights; + lighting.EnvLight0Colour = lights.Count > 0 ? lights[0].Colour : Vector3.Zero; + lighting.EnvDirection0 = lights.Count > 0 ? lights[0].Direction : Vector3.UnitY; + lighting.EnvLight1Colour = lights.Count > 1 ? lights[1].Colour : Vector3.Zero; + lighting.EnvDirection1 = lights.Count > 1 ? lights[1].Direction : Vector3.UnitY; + } + // Measures the region the image will occupy and samples the mouse against it, which everything + // below depends on: the texture size, the camera controls, and the click latch. + _viewport.Begin("view3d"); UpdateWindowSize(); Tick(deltaTime); - commandList.Begin(); - commandList.SetFramebuffer(renderTexture.Framebuffer); - commandList.ClearColorTarget(0, new RgbaFloat(0, 0, 0, 1)); - // Explicit stencil=0: SelectionOutlineRenderer's mask pass depends on stencil starting - // clean every frame, and nothing else in this render path touches it. - commandList.ClearDepthStencil(1.0f, 0); - - Camera.Begin(commandList); - Camera.Update(deltaTime); - - immediateRenderer.Begin(commandList, renderTexture.Framebuffer.OutputDescription); - EntityManager.Singleton.Draw(renderer, renderTexture.Framebuffer.OutputDescription, commandList, Camera, immediateRenderer); - renderer.Draw(commandList, renderTexture.Framebuffer.OutputDescription); - - // Drawn after the main opaque pass (not from inside Entity.Draw) since the inflated-hull - // outline technique needs real scene depth already written to correctly clip to the rim. - // Volumes opt out entirely: EntityVolume already recolors its own wireframe box on - // selection (see its Draw()), and the inflated-hull technique expects one closed mesh — - // a volume's pick/wire mesh is 12 disjoint GPU-instanced edges, not a closed surface, so - // inflating along vertex normals would produce a patchy, disconnected-looking rim instead - // of a clean outline. - if (SelectedEntity != null && SelectedEntity is not EntityVolume) + // New-renderer Stage 12 (Docs/NewRenderer.md): once the scene has drawn at least once (so its + // instances are known), assemble the WHOLE scene from the geometry registry + EntityManager's + // live per-instance world transforms and hand it to the raw-Vulkan renderer, which records one + // indexed draw per instance ONCE and replays it into a display texture with the LIVE camera. + // + // AssetManager owns capturing/building the scene (see TryCaptureScene's remarks) - this call is + // a no-op once it has already been captured, including across this panel being closed and + // reopened, which is the whole point: the captured scene's lifetime is the LEVEL's, not this + // panel's. It also stays a no-op while textures are still uploading (see AssetManager's queued- + // upload drain, spread across frames by Window.DoLoadEntitiesCheck instead of blocking one), + // so the very first capture never samples a texture before its pixel data has actually landed. + bool hadStage = VkStage != null; + Core.LunaWindow.Instance.AssetManager?.TryCaptureScene(graphicsDevice, viewWidth, viewHeight); + var vkStage = VkStage; + // A freshly captured renderer holds no debug lines yet, so force the bounding-sphere overlay + // to be re-pushed rather than assuming the flag still matches what the previous one was given. + if (!hadStage && vkStage != null) _appliedBoundingSpheres = false; + + // "3D Record" is now the whole CPU cost of the view: refreshing the camera, pushing the + // selection's transforms, and the renderer's own cull + re-record + submit. The old + // Record/Submit split measured a Bliss command list that no longer exists; the renderer's + // internal "Vk Cull" / "Vk Record" samples are the finer breakdown. + var record = FrameProfiler.Sample("3D Record"); + Camera.Update(); + + // Replay the raw-Vulkan scene AFTER Camera.Update: Tick moves the camera, Update rebuilds the + // matrices from that, and only then is the view handed over. Sampling earlier gave a view one + // frame behind the position, which made reflections and parallax (both driven by + // uCameraPosition) run visibly "ahead" of the geometry. + if (vkStage != null) { - var entries = SelectedEntity.GetPickableMeshes(); - selectionOutlineRenderer.DrawOutline( - commandList, renderTexture.Framebuffer.OutputDescription, - Camera.GetView() * Camera.GetProjection(), entries, - Program.Settings.SelectionOutlineColor); + try + { + // A gizmo edit only moves matrices, never the draw list, so the selected entity's + // transforms are rewritten in place in the renderer's SSBO instead of rebuilding or + // re-recording the scene. Only the selection is pushed: it is the only thing that can + // move in the editor, and it is a handful of matrices. + if (SelectedEntity is { } moved) + Core.LunaWindow.Instance.AssetManager?.UpdateEntityTransforms(moved); + + UpdateBoundingSphereOverlay(); + + vkStage.Frame( + Camera.GetView(), Camera.GetProjection(), + lighting.BuildLightData(Camera.Position), + BuildVolumeList(), EntityManager.Singleton.VolumeWireThickness, + // Volumes opt out of the outline: EntityVolume already recolours its own wireframe + // on selection, and the mask-and-inflate technique expects one closed surface, not + // 12 disjoint edge boxes. + SelectedEntity is EntityVolume ? null : SelectedEntity, + Program.Settings.SelectionOutlineColor, 0.006f, + // True world-space position, same convention as lighting.BuildLightData(Camera.Position) + // just above and as Entity.WorldBoundingSphere (what _instCenter/Visible() compares + // this against) - negating it here used to feed the distance-cull test a mirrored + // camera position, so a moby could cross its display-distance threshold in the wrong + // direction as the real camera moved closer, making it disappear when it should not. + Camera.Position, EntityManager.Singleton.MobyDistanceCullingEnabled, + // Lit/unlit is a live switch in the shader now, not a rebuild: the setting used to + // pick a different Bliss Effect per material, which meant every material had to be + // rebuilt to change it. + Program.Settings.EnableLighting, + EntityManager.Singleton.FrustumCullingEnabled, + VisibleEntityKinds(), + Program.Settings.TextureFiltering); + + // The overlay's "entities rendered" readout used to be incremented by each entity's + // Bliss Draw. That path is gone, so it comes from the renderer's own post-cull visible + // count instead - which is the same quantity, measured where the culling now happens. + Engine.Scene.Entity.EntitiesRenderedThisFrame = vkStage.VisibleDrawCount; + } + catch (Exception e) { LunaLog.LogError($"[VkRenderer] frame failed: {e.Message}"); Core.LunaWindow.Instance.AssetManager?.InvalidateSceneRenderer(); } } - immediateRenderer.End(); - - Camera.End(); - - commandList.End(); - graphicsDevice.SubmitCommands(commandList); + record.Dispose(); - var viewportPos = ImGui.GetCursorScreenPos(); // No UV flip needed: the render texture already comes out right-side up and correctly // oriented left/right. A prior commit added a horizontal flip here that mirrored the - // whole 3D view (reported as "ties/world mirrored on X and Z") — removed, along with the + // whole 3D view (reported as "ties/world mirrored on X and Z"), removed along with the // matching compensations it forced into PickEntityUnderCursor, GizmoController and // AxisGizmoRenderer. - ImGui.Image( - Core.LunaWindow.Instance.imGuiController.GetOrCreateImGuiBinding(graphicsDevice.ResourceFactory, renderTexture.ColorTexture), - new Vector2(renderTexture.Width, renderTexture.Height), - Vector2.Zero, - Vector2.One); - var viewportSize = new Vector2(renderTexture.Width, renderTexture.Height); - GizmoController.Render(Camera, SelectedEntity, viewportPos, viewportSize); + // The raw-Vulkan renderer renders the scene into its own display texture; before a level is + // captured there is simply nothing to show, so the panel stays empty rather than falling back + // to a Bliss target. + if (vkStage != null) + _viewport.DrawImage(Core.LunaWindow.Instance.imGuiController.GetOrCreateImGuiBinding(graphicsDevice.ResourceFactory, vkStage.ColorTexture)); + else + _viewport.DrawEmpty(); + + // Overlay, then gizmo, then picking. That is the priority order, and it is the call order: + // each one gets its chance at the click before the next, and TryConsumeClick below returns + // true only for a click none of them wanted. + DrawViewportOverlay(); + _viewport.Gizmo(GizmoController, Camera, SelectedEntity); + + var viewportSize = _viewport.Size; if (viewportSize.X >= 120f && viewportSize.Y >= 120f) - AxisGizmoRenderer.Draw(Camera, viewportPos + new Vector2(viewportSize.X - 55f, 55f), 28f); - - // Must run after GizmoController.Render(): IsUsing/IsOver only reflect this frame's - // gizmo hit-test once Manipulate() above has run. Checking them any earlier sees last - // frame's (stale) value, so a click on a gizmo handle would fall through to picking - // instead of starting the drag. IsOver is also needed alongside IsUsing because - // IsUsingAny() itself lags a frame behind the initial click-down (it wants a drag delta - // first) — without it, the very first click on a handle would still leak through. - if (pickRequested && !GizmoController.IsUsing && !GizmoController.IsOver) + AxisGizmoRenderer.Draw(Camera, _viewport.ScreenPos + new Vector2(viewportSize.X - 55f, 55f), 28f); + + if (_viewport.TryConsumeClick()) PickEntityUnderCursor(); - pickRequested = false; + + _viewport.End(); } public override void RenderAsWindow(double deltaTime) @@ -194,29 +369,20 @@ public override void RenderAsWindow(double deltaTime) public void UpdateWindowSize() { - if (FrameContentRegion.Width <= 0 || FrameContentRegion.Height <= 0) return; + if (!_viewport.HasArea) return; - if ((int)renderTexture.Width != FrameContentRegion.Width || (int)renderTexture.Height != FrameContentRegion.Height) + if (viewWidth != (uint)_viewport.PixelWidth || viewHeight != (uint)_viewport.PixelHeight) OnResize(); } private void Tick(double deltaTime) { - Vector2 wcravail = ImGui.GetContentRegionAvail(); - int width = (int)wcravail.X, height = (int)wcravail.Y; - - var windowMousePos = Input.GetMousePosition(); - - FrameContentRegion = new Rectangle(0, 0, width, height); - FramePos = ImGui.GetWindowPos(); - MousePos = windowMousePos - (FramePos + FrameContentRegion.GetOriginF()); + // Left click is not read here any more: the viewport latched it in Begin and hands it over at + // TryConsumeClick, after the overlay and the gizmo have had their turn. A rotate-drag claims + // the mouse (see CheckRotationInput), which is what stops a click from also picking. + bool isRotating = CheckRotationInput(_viewport.AllowCameraInput); - Point absMousePos = new((int)windowMousePos.X, (int)windowMousePos.Y); - bool isHoveringWnd = ImGui.IsWindowHovered(); - bool isMouseInCntReg = FrameContentRegion.Contains(absMousePos); - bool isRotating = CheckRotationInput(isHoveringWnd); - - if (!isRotating && !(isHoveringWnd && isMouseInCntReg)) + if (!isRotating && !_viewport.IsHovered) return; HandleShortcuts(); @@ -224,48 +390,43 @@ private void Tick(double deltaTime) if (isRotating) CheckMovementInput(deltaTime); else - { HandleGizmoShortcuts(); - if (Input.IsMouseButtonPressed(MouseButton.Left)) - pickRequested = true; - } } - private bool pickRequested; - private void PickEntityUnderCursor() { - if (FrameContentRegion.Width <= 0 || FrameContentRegion.Height <= 0) return; + if (!_viewport.HasArea) return; + var vkStage = VkStage; + if (vkStage == null) { SelectedEntity = null; return; } - var entities = EntityManager.Singleton.AllEntities().ToList(); - var entries = entities.SelectMany(e => - { - uint id = (uint)e.ID; - return e.GetPickableMeshes().Select(pm => (pm.mesh, pm.world, id)); - }); - - uint hitId; try { - hitId = pickingRenderer.Pick( - (uint)FrameContentRegion.Width, (uint)FrameContentRegion.Height, - (int)MousePos.X, (int)MousePos.Y, - Camera.GetView() * Camera.GetProjection(), - entries); + // The raw-Vulkan renderer picks straight out of the scene it already holds: it narrows the + // projection to the few pixels under the cursor, which shrinks the target AND gives a + // frustum that rejects everything else before a draw is issued. + uint hitId = vkStage.Pick( + Camera.GetView(), Camera.GetProjection(), + (int)_viewport.MousePos.X, (int)_viewport.MousePos.Y, + _viewport.Size.X, _viewport.Size.Y); + + SelectedEntity = hitId != Engine.Rendering.Vulkan.VulkanRenderer.NoHit + ? EntityManager.Singleton.AllEntities().FirstOrDefault(e => (uint)e.ID == hitId) + : null; } catch (Exception e) { LunaLog.LogError($"Picking failed: {e}"); - return; } - - SelectedEntity = hitId != PickingRenderer.NoHit ? entities.FirstOrDefault(e => (uint)e.ID == hitId) : null; } protected void OnResize() { - renderTexture.Resize((uint)FrameContentRegion.Width, (uint)FrameContentRegion.Height); - Camera.Resize((uint)FrameContentRegion.Width, (uint)FrameContentRegion.Height); + viewWidth = (uint)_viewport.PixelWidth; + viewHeight = (uint)_viewport.PixelHeight; + Camera.Resize(viewWidth, viewHeight); + // Keep the raw-Vulkan display texture (Stage 12) matched to the panel so ImGui shows it 1:1. + try { VkStage?.Resize(graphicsDevice, viewWidth, viewHeight); } + catch (Exception e) { LunaLog.LogError($"[VkRenderer] resize failed: {e.Message}"); Core.LunaWindow.Instance.AssetManager?.InvalidateSceneRenderer(); } } public void HandleShortcuts() @@ -273,7 +434,7 @@ public void HandleShortcuts() if (Input.IsKeyPressed(KeyboardKey.Escape)) SelectedEntity = null; } - /// Gizmo tool shortcuts — only when NOT in camera movement mode, to avoid clashing with WASD. + /// Gizmo tool shortcuts - only when NOT in camera movement mode, to avoid clashing with WASD. public void HandleGizmoShortcuts() { if (Input.IsKeyPressed(KeyboardKey.W)) GizmoController.CurrentOperation = Hexa.NET.ImGuizmo.ImGuizmoOperation.Translate; @@ -285,16 +446,12 @@ private bool CheckRotationInput(bool allowGrab) { if (GizmoController.IsUsing) return false; - ImGuiIOPtr io = ImGui.GetIO(); - if (rmbghandler.TryGrabMouse(allowGrab)) - { - io.ConfigFlags |= ImGuiConfigFlags.NoMouse; - } - else - { - io.ConfigFlags &= ~ImGuiConfigFlags.NoMouse; - return false; - } + // The viewport owns relative mouse mode: it is one global flag shared with every other 3D + // view, so only the viewport that turned it on turns it off again. Reporting the drag here + // also claims the mouse, which is what keeps a rotate-drag from registering as a pick. + bool rotating = rmbghandler.TryGrabMouse(allowGrab); + _viewport.SetMouseCaptured(rotating); + if (!rotating) return false; Vector2 rot = Input.GetMouseDelta(); rot *= Program.Settings.CamSensivity; diff --git a/ReLunacy/Core/Frames/FileSelectionDialog.cs b/ReLunacy/Core/Frames/FileSelectionDialog.cs index 6804265..caa9b3b 100644 --- a/ReLunacy/Core/Frames/FileSelectionDialog.cs +++ b/ReLunacy/Core/Frames/FileSelectionDialog.cs @@ -1,4 +1,3 @@ -using Bliss.CSharp.Interact; using ReLunacy.Core.Frames.Modals; using ReLunacy.Utility; using ReLunacy.Utility.Localization; diff --git a/ReLunacy/Core/Frames/Modals/ExportResultModal.cs b/ReLunacy/Core/Frames/Modals/ExportResultModal.cs index da782a8..14e59fd 100644 --- a/ReLunacy/Core/Frames/Modals/ExportResultModal.cs +++ b/ReLunacy/Core/Frames/Modals/ExportResultModal.cs @@ -4,7 +4,7 @@ namespace ReLunacy.Core.Frames.Modals; -/// Shown once a background model export (see AssetViewer's export buttons) finishes — +/// Shown once a background model export (see AssetViewer's export buttons) finishes - /// on success, offers to open the OS file explorer at the output folder. public class ExportResultModal : Modal { diff --git a/ReLunacy/Core/Frames/Modals/LevelExportModal.cs b/ReLunacy/Core/Frames/Modals/LevelExportModal.cs index 56a147f..9ceac8e 100644 --- a/ReLunacy/Core/Frames/Modals/LevelExportModal.cs +++ b/ReLunacy/Core/Frames/Modals/LevelExportModal.cs @@ -8,7 +8,7 @@ namespace ReLunacy.Core.Frames.Modals; /// Lets the user pick which categories (Mobys/Ties/UFrags) to include before exporting -/// the whole loaded level to a single .glb — see LevelExporter for why this is glTF-only (OBJ has +/// the whole loaded level to a single .glb - see LevelExporter for why this is glTF-only (OBJ has /// no node hierarchy or mesh instancing, both of which whole-level export depends on). public class LevelExportModal : Modal { diff --git a/ReLunacy/Core/Frames/Modals/UpdateInfoFrame.cs b/ReLunacy/Core/Frames/Modals/UpdateInfoFrame.cs index f43625f..01de402 100644 --- a/ReLunacy/Core/Frames/Modals/UpdateInfoFrame.cs +++ b/ReLunacy/Core/Frames/Modals/UpdateInfoFrame.cs @@ -1,4 +1,3 @@ -using System.Diagnostics; using System.Numerics; using ReLunacy.Utility; using ReLunacy.Utility.Localization; @@ -17,14 +16,18 @@ public class UpdateInfoFrame : Modal private readonly string newVersionLabel; private readonly DateTime releaseDate; private readonly bool isNightly; + private readonly string? changelog; + private readonly IReadOnlyList? commits; - public UpdateInfoFrame(string url, string newVersionLabel, DateTime releaseDate, bool isNightly = false) + public UpdateInfoFrame(string url, string newVersionLabel, DateTime releaseDate, bool isNightly = false, string? changelog = null, IReadOnlyList? commits = null) { FrameName = LM.Get("GUI_Frame_UpdateInfo_Title"); link = url; this.newVersionLabel = newVersionLabel; this.releaseDate = releaseDate; this.isNightly = isNightly; + this.changelog = changelog; + this.commits = commits; } protected override void Render(double deltaTime) @@ -48,12 +51,45 @@ protected override void Render(double deltaTime) : LM.Get("GUI_Frame_UpdateInfo_MinutesAgo", (int)diff.TotalMinutes); ImGui.Text(LM.Get("GUI_Frame_UpdateInfo_Released", ago, releaseDate.ToString("dd/MM/yyyy HH:mm:ss"))); + if (!string.IsNullOrWhiteSpace(changelog)) + { + ImGui.Spacing(); + ImGui.SeparatorText(LM.Get("GUI_Frame_UpdateInfo_Changelog")); + ImGui.Spacing(); + + // Fixed-size scrolling region so a long release body doesn't grow the auto-resizing + // modal past the screen; the markdown wraps to this child's width. + if (ImGui.BeginChild("changelog", new Vector2(560, 300), ImGuiChildFlags.Borders)) + MarkdownRenderer.Render(changelog); + ImGui.EndChild(); + } + + if (commits is { Count: > 0 }) + { + ImGui.Spacing(); + ImGui.SeparatorText(LM.Get("GUI_Frame_UpdateInfo_Commits", commits.Count)); + ImGui.Spacing(); + + if (ImGui.BeginChild("commits", new Vector2(560, 160), ImGuiChildFlags.Borders)) + { + foreach (var commit in commits) + { + ImGui.Bullet(); + ImGui.SameLine(); + ImGuiPlus.Hyperlink(commit.ShortSha, commit.Url); + ImGui.SameLine(); + ImGui.TextWrapped(commit.Message); + } + } + ImGui.EndChild(); + } + ImGui.Spacing(); ImGui.Separator(); ImGui.Spacing(); if (ImGuiPlus.CenteredButton(LM.Get("GUI_Frame_UpdateInfo_Download"), new Vector2(150, 40))) - Process.Start(new ProcessStartInfo(link) { UseShellExecute = true }); + ShellUtils.OpenUrl(link); ImGui.SameLine(); if (ImGui.Button(LM.Get("GUI_Common_CloseWord"))) diff --git a/ReLunacy/Core/ILevelListener.cs b/ReLunacy/Core/ILevelListener.cs index d040020..abf585f 100644 --- a/ReLunacy/Core/ILevelListener.cs +++ b/ReLunacy/Core/ILevelListener.cs @@ -2,8 +2,8 @@ namespace ReLunacy.Core; /// /// Implemented by frames that cache anything derived from the currently loaded level (entities, -/// built models, textures, selection results, ...). LunaWindow calls these on every open frame — -/// via openFrames.OfType<ILevelListener>(), not a hard-coded per-frame-type list — so a +/// built models, textures, selection results, ...). LunaWindow calls these on every open frame - +/// via openFrames.OfType<ILevelListener>(), not a hard-coded per-frame-type list - so a /// new frame that starts caching level data just has to implement this interface instead of /// requiring a matching edit inside LunaWindow itself. /// @@ -12,7 +12,7 @@ public interface ILevelListener /// /// Called right before the current level's EntityManager/AssetManager/FileManager get /// disposed. Drop every reference to level-derived data here (entities, Models, ITextures, - /// cached search/usage results) — anything still held past this point is a dangling reference + /// cached search/usage results) - anything still held past this point is a dangling reference /// to an object that's about to be destroyed. Don't dispose AssetManager-owned resources /// yourself; only clear references and dispose whatever the frame itself uniquely owns. /// diff --git a/ReLunacy/Core/MenuBar/RenderMenuDraw.cs b/ReLunacy/Core/MenuBar/RenderMenuDraw.cs index fed9630..0f01825 100644 --- a/ReLunacy/Core/MenuBar/RenderMenuDraw.cs +++ b/ReLunacy/Core/MenuBar/RenderMenuDraw.cs @@ -8,37 +8,76 @@ internal static class RenderMenuDraw { internal static void ShowMobys() { - if (!ImGui.MenuItem(LM.Get("GUI_MenuItem_RenderMobys"), "", EntityManager.Singleton.renderMobys, !Program.Settings.LegacyRenderingMode)) return; + if (!ImGui.MenuItem(LM.Get("GUI_MenuItem_RenderMobys"), "", EntityManager.Singleton.renderMobys)) return; EntityManager.Singleton.renderMobys = !EntityManager.Singleton.renderMobys; } internal static void ShowTies() { - if (!ImGui.MenuItem(LM.Get("GUI_MenuItem_RenderTies"), "", EntityManager.Singleton.renderTies, !Program.Settings.LegacyRenderingMode)) return; + if (!ImGui.MenuItem(LM.Get("GUI_MenuItem_RenderTies"), "", EntityManager.Singleton.renderTies)) return; EntityManager.Singleton.renderTies = !EntityManager.Singleton.renderTies; } internal static void ShowUFrags() { - if (!ImGui.MenuItem(LM.Get("GUI_MenuItem_RenderUFrags"), "", EntityManager.Singleton.renderUFrags, !Program.Settings.LegacyRenderingMode)) return; + if (!ImGui.MenuItem(LM.Get("GUI_MenuItem_RenderUFrags"), "", EntityManager.Singleton.renderUFrags)) return; EntityManager.Singleton.renderUFrags = !EntityManager.Singleton.renderUFrags; } + internal static void ShowFoliage() + { + if (!ImGui.MenuItem(LM.Get("GUI_MenuItem_RenderFoliage"), "", EntityManager.Singleton.renderFoliage)) return; + EntityManager.Singleton.renderFoliage = !EntityManager.Singleton.renderFoliage; + } + internal static void ShowVolumes() { - if (!ImGui.MenuItem(LM.Get("GUI_MenuItem_RenderVolumes"), "", EntityManager.Singleton.renderVolumes, !Program.Settings.LegacyRenderingMode)) return; + if (!ImGui.MenuItem(LM.Get("GUI_MenuItem_RenderVolumes"), "", EntityManager.Singleton.renderVolumes)) return; EntityManager.Singleton.renderVolumes = !EntityManager.Singleton.renderVolumes; } internal static void ShowBoundingSpheres() { - if (!ImGui.MenuItem(LM.Get("GUI_MenuItem_RenderBoundingSpheres"), "", EntityManager.Singleton.renderBoundingSpheres, !Program.Settings.LegacyRenderingMode)) return; + if (!ImGui.MenuItem(LM.Get("GUI_MenuItem_RenderBoundingSpheres"), "", EntityManager.Singleton.renderBoundingSpheres)) return; EntityManager.Singleton.renderBoundingSpheres = !EntityManager.Singleton.renderBoundingSpheres; } internal static void ShowMobyDistanceCulling() { - if (!ImGui.MenuItem(LM.Get("GUI_MenuItem_MobyDistanceCulling"), "", EntityManager.Singleton.MobyDistanceCullingEnabled, !Program.Settings.LegacyRenderingMode)) return; + if (!ImGui.MenuItem(LM.Get("GUI_MenuItem_MobyDistanceCulling"), "", EntityManager.Singleton.MobyDistanceCullingEnabled)) return; EntityManager.Singleton.MobyDistanceCullingEnabled = !EntityManager.Singleton.MobyDistanceCullingEnabled; } + + // Cubemap reflection controls (lit renderer only). The reflection term is faithfully gated by + // the material's specular map and a low intensity, so it's near-invisible by default - the debug + // view shows it raw on everything (also an axis-orientation check), and the slider makes the + // normal-shading contribution tunable. Reached through View3D, which owns the renderer. + internal static void ReflectionControls() + { + var view = LunaWindow.Instance.GetFirstFrame(); + if (view == null) return; + + if (ImGui.MenuItem(LM.Get("GUI_MenuItem_ReflectionDebug"), "", view.ReflectionDebugView)) + view.ReflectionDebugView = !view.ReflectionDebugView; + + float intensity = view.ReflectionIntensity; + ImGui.SetNextItemWidth(120); + if (ImGui.SliderFloat(LM.Get("GUI_MenuItem_ReflectionIntensity"), ref intensity, 0f, 2f, "%.2f")) + view.ReflectionIntensity = intensity; + + // Reflectivity floor (Fresnel F0): 0 reflects only where the specular map says to, 1 is a + // near-mirror everywhere. The game reads as reflective almost everywhere, so raise this. + float reflBase = view.ReflectionBase; + ImGui.SetNextItemWidth(120); + if (ImGui.SliderFloat(LM.Get("GUI_MenuItem_ReflectionBase"), ref reflBase, 0f, 1f, "%.2f")) + view.ReflectionBase = reflBase; + + // Floor under a baked surface, as a fraction of the ambient fill. 0 is the game-faithful + // reconstruction and bottoms out to pure black wherever the parallax normal tilts past the + // baked light direction; raise it until the crevices read without flattening the bake. + float bakedAmbient = view.BakedAmbient; + ImGui.SetNextItemWidth(120); + if (ImGui.SliderFloat(LM.Get("GUI_MenuItem_BakedAmbient"), ref bakedAmbient, 0f, 1f, "%.2f")) + view.BakedAmbient = bakedAmbient; + } } diff --git a/ReLunacy/Core/MenuBar/ViewMenuDraw.cs b/ReLunacy/Core/MenuBar/ViewMenuDraw.cs index b5830f3..8a16307 100644 --- a/ReLunacy/Core/MenuBar/ViewMenuDraw.cs +++ b/ReLunacy/Core/MenuBar/ViewMenuDraw.cs @@ -25,6 +25,30 @@ internal static void ShowView3D() LunaWindow.Instance.AddFrame(new View3D(LunaWindow.Instance.GraphicsDevice)); } + internal static void ShowLevelData() + { + bool frameAlreadyOpen = LunaWindow.Instance.IsAnyFrameOpened(); + if (!ImGui.MenuItem(ImGuiPlus.Label(Icons.Map, LM.Get("GUI_Frame_LevelData")), "", frameAlreadyOpen, true)) + return; + + if (frameAlreadyOpen) + LunaWindow.Instance.TryCloseFirstFrame(); + else + LunaWindow.Instance.AddFrame(new LevelDataFrame()); + } + + internal static void ShowProfiler() + { + bool frameAlreadyOpen = LunaWindow.Instance.IsAnyFrameOpened(); + if (!ImGui.MenuItem(LM.Get("GUI_Frame_Profiler"), "", frameAlreadyOpen, true)) + return; + + if (frameAlreadyOpen) + LunaWindow.Instance.TryCloseFirstFrame(); + else + LunaWindow.Instance.AddFrame(new ProfilerFrame()); + } + internal static void ShowEntityExplorer() { bool frameAlreadyOpen = LunaWindow.Instance.IsAnyFrameOpened(); diff --git a/ReLunacy/Core/Overlay.cs b/ReLunacy/Core/Overlay.cs index dfb1f14..9dab707 100644 --- a/ReLunacy/Core/Overlay.cs +++ b/ReLunacy/Core/Overlay.cs @@ -40,8 +40,8 @@ public static void DrawOverlay(bool p_open) if (Location is >= 0 and <= 3) { - Vector2 workPos = useView && view != null ? view.FramePos + view.FrameContentRegion.GetOriginF() : viewport.WorkPos; - Vector2 workSize = useView && view != null ? view.FrameContentRegion.GetSizeF() : viewport.WorkSize; + Vector2 workPos = useView && view != null ? view.ViewportScreenPos : viewport.WorkPos; + Vector2 workSize = useView && view != null ? view.ViewportSize : viewport.WorkSize; Vector2 windowPos, windowPosPivot; windowPos.X = Location is 1 or 3 ? workPos.X + workSize.X - Padding.X : workPos.X + Padding.X; windowPos.Y = Location >= 2 ? workPos.Y + workSize.Y - Padding.Y : workPos.Y + Padding.Y; @@ -52,7 +52,10 @@ public static void DrawOverlay(bool p_open) } else if (Location == 4) { - ImGui.SetNextWindowPos(useView && view != null ? view.FrameContentRegion.GetCenterF() : ImGui.GetWorkCenter(viewport), ImGuiCond.Always, new Vector2(0.5f, 0.5f)); + // Centre of the rendered image in SCREEN space. This used to read the centre of a + // zero-origin rectangle, which is half the panel size measured from the top-left of the + // monitor, so the centred overlay landed nowhere near the view. + ImGui.SetNextWindowPos(useView && view != null ? view.ViewportScreenPos + view.ViewportSize * 0.5f : ImGui.GetWorkCenter(viewport), ImGuiCond.Always, new Vector2(0.5f, 0.5f)); flags |= ImGuiWindowFlags.NoMove; } @@ -119,8 +122,8 @@ public static void DrawOverlay(bool p_open) ImGui.SeparatorText(LM.Get("GUI_Overlay_CameraStats")); ImGui.BeginGroup(); ImGui.Text($"{LM.Get("GUI_Overlay_CameraPosition")}: {view.Camera.Position:N3}"); - ImGui.Text($"{LM.Get("GUI_Overlay_CameraRotation")}: ({x:N3}°, {y:N3}°)"); - ImGui.Text($"{LM.Get("GUI_Overlay_Resolution")}: ({view.FrameContentRegion.Width}x{view.FrameContentRegion.Height})"); + ImGui.Text($"{LM.Get("GUI_Overlay_CameraRotation")}: ({x:N3} deg, {y:N3} deg)"); + ImGui.Text($"{LM.Get("GUI_Overlay_Resolution")}: ({(int)view.ViewportSize.X}x{(int)view.ViewportSize.Y})"); ImGui.EndGroup(); } } diff --git a/ReLunacy/Core/Window.cs b/ReLunacy/Core/Window.cs index 8ef0aa4..d24f09b 100644 --- a/ReLunacy/Core/Window.cs +++ b/ReLunacy/Core/Window.cs @@ -1,18 +1,11 @@ using System.Collections.Concurrent; using System.Diagnostics.CodeAnalysis; using System.Numerics; -using Bliss.CSharp; -using Bliss.CSharp.Fonts; -using Bliss.CSharp.Graphics.Rendering.Renderers; -using Bliss.CSharp.Images; -using Bliss.CSharp.Interact; -using Bliss.CSharp.Interact.Contexts; -using Bliss.CSharp.Textures; -using Bliss.CSharp.Windowing; using ReLunacy.Core.Frames; using ReLunacy.Core.Frames.DockedFrames; using ReLunacy.Core.Frames.Modals; using ReLunacy.Core.Selection; +using ReLunacy.Engine.Diagnostics; using ReLunacy.Engine.Loading.IO; using ReLunacy.Engine.Loading.Readers; using ReLunacy.Engine.Rendering; @@ -24,21 +17,20 @@ namespace ReLunacy.Core; -public class LunaWindow : Disposable +public class LunaWindow : IDisposable { [NotNull] public static LunaWindow? Instance { get; private set; } public EditorSettings EditorSettings => Program.Settings; public ResourcesManager Resources => Program.Resources; - [NotNull] public IWindow? MainWindow { get; private set; } + [NotNull] public EditorWindow? MainWindow { get; private set; } [NotNull] public GraphicsDevice? GraphicsDevice { get; private set; } [NotNull] public CommandList? CommandList { get; private set; } private double fixedFrameRate; private double fixedUpdateTimeStep; private double fixedUpdateTimer; - public FullScreenRenderer FullScreenRenderer { get; private set; } = null!; - public RenderTexture2D FullScreenTexture { get; private set; } = null!; - public Texture2D FinalFullScreenTexture { get; private set; } = null!; + public FullscreenBlit FullScreenRenderer { get; private set; } = null!; + public MainRenderTarget FullScreenTexture { get; private set; } = null!; public ImGuiController imGuiController = null!; public List openFrames = []; @@ -46,10 +38,18 @@ public class LunaWindow : Disposable public FileManager? fileManager { get; private set; } public AssetManager? AssetManager { get; private set; } public LevelData? Level { get; private set; } - private bool doLoadEntities; + + /// The level and its background-decoded textures, handed from LoadLevelDataAsync's + /// background task to on the main thread - queued rather than + /// signalled through a bare flag (the previous doLoadEntities/pendingTextures pair), same pattern + /// as , so the level and its textures arrive as one atomic + /// message instead of two fields a reader could observe half-set. + private readonly ConcurrentQueue pendingLevelReady = new(); + + private readonly record struct LevelReady(LevelData Level, Dictionary PreparedTextures); /// Background export tasks (see AssetViewer) can only touch - /// from the main thread, same rule as the rest of this class — so completions are queued here + /// from the main thread, same rule as the rest of this class - so completions are queued here /// (ConcurrentQueue needs no external locking) and drained on the main thread each frame by /// . private readonly ConcurrentQueue pendingExportCompletions = new(); @@ -95,8 +95,8 @@ public void Run() SwapchainSrgbFormat = false }; - MainWindow = Window.CreateWindow( - WindowType.Sdl3, 1280, 720, ProgramInfo.DisplayName, WindowState.Resizable, options, + MainWindow = EditorWindow.Create( + 1280, 720, ProgramInfo.DisplayName, options, EditorSettings.GraphicsBackend, out GraphicsDevice graphicsDevice); MainWindow.Resized += () => OnResize(MainWindow.GetWidth(), MainWindow.GetHeight()); GraphicsDevice = graphicsDevice; @@ -104,16 +104,22 @@ public void Run() var wndIcon = Resources.GetWindowIcon(); if (wndIcon != null) MainWindow.SetIcon(wndIcon); + // The renderer's shaders are compile-time constants, so their SPIR-V can be built before any + // level exists. Doing it here on a worker takes ~2.7s of glslang off the middle of the first + // level load, where it was pure freeze, and puts it under the file browser where nothing waits + // on it. Fire-and-forget on purpose: a level load that beats it simply compiles what it needs. + Task.Run(() => + { + try { Engine.Rendering.Vulkan.VulkanRenderer.WarmUpShaderCache(); } + catch (Exception e) { LunaLog.LogError($"[VkRenderer] shader warm-up failed: {e.Message}"); } + }); + Time.Init(); SetTargetFPS(EditorSettings.TargetFPS); CommandList = graphicsDevice.ResourceFactory.CreateCommandList(); - GlobalResource.Init(graphicsDevice); - - if (MainWindow is not Sdl3Window) - throw new NotSupportedException("Unsupported window type for input context."); - Input.Init(new Sdl3InputContext(MainWindow)); + Input.Init(MainWindow); Init(); @@ -123,11 +129,32 @@ public void Run() continue; Time.Update(); - MainWindow.PumpEvents(); - Input.Begin(); - imGuiController.Update((float)Time.Delta); - Update(Time.Delta); + // Only instrument when something is actually displaying the breakdown - the scopes are + // cheap (a Stopwatch read each) but there is no reason to pay even that when nothing + // reads it. Read one frame ahead of BeginFrame is fine: toggling the frame on simply + // starts collecting on the next frame. + FrameProfiler.Enabled = + IsAnyFrameOpened() || (Overlay.showOverlay && Overlay.ShowProfiler); + FrameProfiler.BeginFrame(); + + using (FrameProfiler.Sample("Events")) + { + // Must snapshot the previous frame's state before this frame's SDL events land: Begin() + // copies _mouseDown/_keysDown into the "last" arrays that edge-triggered queries + // (IsMouseButtonPressed, IsKeyPressed) compare against. Pumping first would let a + // button-down event this frame land in _mouseDown before Begin() copies it into + // _mouseDownLast too, making the press-edge unobservable for as long as the button + // stays held. + Input.Begin(); + MainWindow.PumpEvents(); + } + + using (FrameProfiler.Sample("ImGui NewFrame")) + imGuiController.Update((float)Time.Delta); + + using (FrameProfiler.Sample("Update")) + Update(Time.Delta); fixedUpdateTimer += Time.Delta; while (fixedUpdateTimer >= fixedUpdateTimeStep) @@ -136,9 +163,12 @@ public void Run() fixedUpdateTimer -= fixedUpdateTimeStep; } - Draw(graphicsDevice, CommandList); + using (FrameProfiler.Sample("Draw")) + Draw(graphicsDevice, CommandList); + AfterUpdate(); Input.End(); + FrameProfiler.EndFrame(); } LunaLog.LogInfo("Shutting down..."); @@ -156,10 +186,9 @@ public async void PeriodicalSave() protected virtual void Init() { - FullScreenRenderer = new FullScreenRenderer(GraphicsDevice); - var (width, height) = (MainWindow.GetWidth(), MainWindow.GetHeight()); - FullScreenTexture = new RenderTexture2D(GraphicsDevice, (uint)width, (uint)height, false, (TextureSampleCount)EditorSettings.MSAA_Level); - FinalFullScreenTexture = new Texture2D(GraphicsDevice, new Image(width, height), false); + FullScreenRenderer = new FullscreenBlit(GraphicsDevice); + var (width, height) = MainWindow.GetSizeInPixels(); + FullScreenTexture = new MainRenderTarget(GraphicsDevice, (uint)width, (uint)height, (TextureSampleCount)EditorSettings.MSAA_Level); imGuiController = new ImGuiController(GraphicsDevice, FullScreenTexture.Framebuffer.OutputDescription, (int)FullScreenTexture.Width, (int)FullScreenTexture.Height); LM.Initialize(); @@ -172,7 +201,7 @@ protected virtual void Init() } /// - /// User-picked debug.dat, set via the "Load a debug.dat" tab — takes priority over whatever + /// User-picked debug.dat, set via the "Load a debug.dat" tab - takes priority over whatever /// auto-detection would otherwise find, and survives across a reload of the same level so the /// tab can be used after the fact to fix a level that loaded without one. /// @@ -193,7 +222,7 @@ await Task.Run(() => fileManager.LoadFolder(path); // Old engine only: debug.dat almost never ships alongside main.dat/the level's own - // .psarc — try, in priority order, whatever the user explicitly picked, then whatever + // .psarc - try, in priority order, whatever the user explicitly picked, then whatever // the caller already resolved (GameBrowserFrame via GameLibraryScanner), then fall // back to deriving it from the path directly (for callers, like the manual "Open // level" dialog, that never went through the scanner at all). @@ -207,13 +236,21 @@ await Task.Run(() => var levelReader = new LevelReader(fileManager); Level = levelReader.LoadLevel((status, progress) => loadingFrame?.UpdateProgress(0, new LoadingProgress(status, 100, true) { current = (uint)(progress * 100) })); + + // Decode every texture and build its mip chain HERE, still on the loading task and in + // parallel across cores. It is the single largest piece of what used to be a main-thread + // freeze after the files had finished reading, and none of it needs the graphics device. + var swPrep = System.Diagnostics.Stopwatch.StartNew(); + var prepared = AssetManager.PrepareTextures(Level); + LunaLog.LogDebug($"Decoded {prepared.Count} textures in {swPrep.ElapsedMilliseconds}ms (loading task, parallel)."); + + pendingLevelReady.Enqueue(new LevelReady(Level, prepared)); }); - doLoadEntities = true; LunaLog.LogDebug("Level loaded."); } - /// Applies a user-picked debug.dat to the currently loaded level by reloading it — + /// Applies a user-picked debug.dat to the currently loaded level by reloading it - /// the reload runs every name through the exact same DebugReader path a normal load does, /// rather than trying to retroactively patch names onto already-built entities. public void LoadExternalDebugDatAndReload(string debugDatPath, LoadingModal? loadingFrame = null) @@ -227,7 +264,7 @@ public void LoadExternalDebugDatAndReload(string debugDatPath, LoadingModal? loa /// Disposes the currently loaded level (EntityManager's GPU meshes, AssetManager's built /// models/textures, FileManager's open file handles) and notifies every open frame that /// implements beforehand, so nothing is left holding a reference - /// to an object that's about to be destroyed — most importantly the current selection, which + /// to an object that's about to be destroyed - most importantly the current selection, which /// otherwise leaves View3D pointing a disposed mesh at the GPU the very next frame. /// public void TryWipeLevel() @@ -240,6 +277,13 @@ public void TryWipeLevel() SelectionManager.Singleton.Deselect(); + // Must go before EntityManager.Dispose(): the captured scene (now owned by AssetManager, see + // its SceneRenderer property) references live entity meshes/geometry and the VulkanSceneCapture + // registry, both of which EntityManager's own disposal below invalidates - otherwise the next + // level captures on top of a stale geometry registry and the renderer keeps buffers for meshes + // that no longer exist. + AssetManager.DisposeSceneRenderer(); + EntityManager.Singleton.Dispose(); AssetManager.Dispose(); fileManager.Dispose(); @@ -248,17 +292,66 @@ public void TryWipeLevel() fileManager = null; AssetManager = null; Program.ProvidedPath = string.Empty; + // In case this level was wiped mid-load, while DoLoadEntitiesCheck was still draining its + // queued texture uploads - without this the next AfterUpdate would see _finalizingLevelLoad + // still true against an AssetManager that's gone, and just no-op until whatever level loads + // next resets both anyway. Harmless either way, but this makes the state honest immediately. + _finalizingLevelLoad = false; + _uploadProgress = null; } + // True from the moment a level's AssetManager/entities are built until its queued texture uploads + // have fully drained - see DoLoadEntitiesCheck. Distinct from doLoadEntities-style polling: this + // spans MANY frames for one level, not just the one frame the transition happens on. + private bool _finalizingLevelLoad; + // The upload phase's own progress bar slot on the loading modal - kept as a direct reference so + // each frame can just mutate .current instead of reconstructing/relocking through UpdateProgress. + private LoadingProgress? _uploadProgress; + + // Per-frame time budget for draining queued texture uploads (see AssetManager.UploadOnePendingTexture). + // Not a count, because texture sizes vary hugely (a 4K atlas vs a 32x32 icon) - a fixed count either + // stalls badly on the big ones or wastes frames doing nothing on the small ones. This runs inside + // AfterUpdate, after this frame's Draw, so spending a bit extra here delays next frame's Present + // rather than corrupting this one - the goal is only to keep it short enough that the loop still + // pumps events and redraws the loading modal every frame instead of one multi-second blocking call. + private const double UploadBudgetMs = 20.0; + private void DoLoadEntitiesCheck() { - if (!doLoadEntities) return; - doLoadEntities = false; + if (pendingLevelReady.TryDequeue(out var ready)) + { + var lsw = System.Diagnostics.Stopwatch.StartNew(); + AssetManager = new AssetManager(ready.Level, GraphicsDevice, ready.PreparedTextures); + long a0 = lsw.ElapsedMilliseconds; + EntityManager.Singleton.LoadRegion(ready.Level.Region, AssetManager, GraphicsDevice); + long tRegion = lsw.ElapsedMilliseconds - a0; a0 = lsw.ElapsedMilliseconds; + EntityManager.Singleton.LoadFoliage(ready.Level.Foliages, AssetManager, GraphicsDevice); + LunaLog.LogDebug($"Entities built in {lsw.ElapsedMilliseconds}ms (region {tRegion}, foliage {lsw.ElapsedMilliseconds - a0}). {AssetManager.TotalQueuedUploads} textures queued for GPU upload."); + + _uploadProgress = new LoadingProgress(LM.Get("GUI_LoadLevelModal_UploadingTextures"), (uint)Math.Max(1, AssetManager.TotalQueuedUploads), true); + GetFirstFrame()?.AddProgress(_uploadProgress); + _finalizingLevelLoad = true; + } + + if (!_finalizingLevelLoad || AssetManager is null) return; - if (Level is null) return; + if (AssetManager.HasPendingUploads) + { + // The same GraphicsDevice.UpdateTexture work AssetManager's constructor always did + // synchronously in one pass - just spread across as many AfterUpdate calls as it takes, + // bounded per call so the window keeps pumping events instead of appearing to hang. + var uploadSw = System.Diagnostics.Stopwatch.StartNew(); + while (AssetManager.HasPendingUploads && uploadSw.Elapsed.TotalMilliseconds < UploadBudgetMs) + AssetManager.UploadOnePendingTexture(); + + if (_uploadProgress != null) + _uploadProgress.current = (uint)(AssetManager.TotalQueuedUploads - AssetManager.PendingUploadCount); + + if (AssetManager.HasPendingUploads) return; // more queued - resume next frame + } - AssetManager = new AssetManager(Level, GraphicsDevice); - EntityManager.Singleton.LoadRegion(Level.Region, AssetManager, GraphicsDevice); + _finalizingLevelLoad = false; + _uploadProgress = null; foreach (var listener in openFrames.OfType()) listener.OnLevelLoaded(); @@ -372,12 +465,14 @@ private void RenderMenuBar() { ViewMenuDraw.ShowOverlay(); ImGui.Separator(); + ViewMenuDraw.ShowLevelData(); ViewMenuDraw.ShowView3D(); ViewMenuDraw.ShowAssetViewer(); ViewMenuDraw.ShowTextureExplorer(); ViewMenuDraw.ShowShaderBrowser(); ViewMenuDraw.ShowEntityExplorer(); ViewMenuDraw.ShowInstanceInspector(); + ViewMenuDraw.ShowProfiler(); ViewMenuDraw.ShowConsoleFrame(); ImGui.Separator(); ViewMenuDraw.ShowPSArcExplorer(); @@ -391,10 +486,13 @@ private void RenderMenuBar() RenderMenuDraw.ShowMobys(); RenderMenuDraw.ShowTies(); RenderMenuDraw.ShowUFrags(); + RenderMenuDraw.ShowFoliage(); RenderMenuDraw.ShowVolumes(); RenderMenuDraw.ShowBoundingSpheres(); ImGui.Separator(); RenderMenuDraw.ShowMobyDistanceCulling(); + ImGui.Separator(); + RenderMenuDraw.ReflectionControls(); ImGui.EndMenu(); } @@ -435,14 +533,15 @@ protected virtual void Update(double deltaTime) Entity.EntitiesRenderedThisFrame = 0; // EntityManager is engine-layer and deliberately doesn't read Program.Settings (see - // AssetManager's decalOffset for the same convention) — so the persisted setting is + // AssetManager's decalOffset for the same convention) - so the persisted setting is // pushed in here every frame instead of being read where it's consumed. Cheap enough // (one bool) to just always do, rather than only on Settings-frame Apply, so a value // loaded from disk at startup takes effect immediately without the user having to open // the Settings frame and toggle the checkbox once first. EntityManager.Singleton.FrustumCullingEnabled = EditorSettings.FrustrumCulling; - AssetManager?.SetBackfaceCulling(EditorSettings.BackfaceCulling); - AssetManager?.SetLightingEnabled(EditorSettings.EnableLighting); + // Lighting is pushed straight to the renderer every frame instead (View3D passes + // EditorSettings.EnableLighting to VulkanRenderer.Frame), and backface culling is baked into + // the renderer's pipelines, so neither goes through the asset manager any more. AssetManager?.SetTextureFiltering(EditorSettings.TextureFiltering); openFrames.RemoveAll(FrameMustClose); @@ -463,40 +562,56 @@ protected virtual void FixedUpdate() { } protected virtual void Draw(GraphicsDevice graphicsDevice, CommandList commandList) { - commandList.Begin(); - commandList.SetFramebuffer(FullScreenTexture.Framebuffer); - commandList.ClearColorTarget(0, new RgbaFloat(0.1f, 0.1f, 0.1f, 1.0f)); - commandList.ClearDepthStencil(1.0f); + using (FrameProfiler.Sample("ImGui Render")) + { + commandList.Begin(); + commandList.SetFramebuffer(FullScreenTexture.Framebuffer); + commandList.ClearColorTarget(0, new RgbaFloat(0.1f, 0.1f, 0.1f, 1.0f)); + commandList.ClearDepthStencil(1.0f); - imGuiController.Render(graphicsDevice, commandList); + imGuiController.Render(graphicsDevice, commandList); - commandList.End(); - graphicsDevice.SubmitCommands(commandList); + commandList.End(); + graphicsDevice.SubmitCommands(commandList); + } - commandList.Begin(); + using (FrameProfiler.Sample("Composite")) + { + commandList.Begin(); - if (FullScreenTexture.SampleCount != TextureSampleCount.Count1) - commandList.ResolveTexture(FullScreenTexture.ColorTexture, FinalFullScreenTexture.DeviceTexture); - else - commandList.CopyTexture(FullScreenTexture.ColorTexture, FinalFullScreenTexture.DeviceTexture); + FullScreenTexture.Resolve(commandList); - commandList.SetFramebuffer(graphicsDevice.SwapchainFramebuffer); - commandList.ClearColorTarget(0, new RgbaFloat(0.1f, 0.1f, 0.1f, 1.0f)); + commandList.SetFramebuffer(graphicsDevice.SwapchainFramebuffer); + commandList.ClearColorTarget(0, new RgbaFloat(0.1f, 0.1f, 0.1f, 1.0f)); - FullScreenRenderer.Draw(commandList, FinalFullScreenTexture, graphicsDevice.SwapchainFramebuffer.OutputDescription); + FullScreenRenderer.Draw(commandList, FullScreenTexture.ResolveTextureView, graphicsDevice.SwapchainFramebuffer.OutputDescription); - commandList.End(); - graphicsDevice.SubmitCommands(commandList); + commandList.End(); + graphicsDevice.SubmitCommands(commandList); + } // Veldrith's Vulkan backend only signals a render-finished semaphore before presenting - // when the present queue differs from the graphics queue — on a shared queue (the common + // when the present queue differs from the graphics queue - on a shared queue (the common // case on desktop GPUs), SwapBuffers's vkQueuePresentKHR call waits on nothing at all, so // without this the presentation engine can read the swapchain image before the GPU has // finished writing it, showing stale/previous-frame content (flicker, visible in both the // 3D viewport and the GUI since both are already composited into this image by here). // WaitForIdle was previously called before this Submit instead of after, which only waited // on the *prior* frame's work and left this exact gap uncovered. - graphicsDevice.WaitForIdle(); - graphicsDevice.SwapBuffers(); + // + // This is also the frame's single most diagnostic number: WaitForIdle blocks the CPU until + // the GPU has drained everything submitted above, so its duration is the GPU tail (see + // FrameProfiler's class summary). If this phase dominates the frame, the bottleneck is the + // GPU or this forced full sync - not CPU submission. + using (FrameProfiler.Sample(FrameProfiler.GpuWaitPhase)) + graphicsDevice.WaitForIdle(); + using (FrameProfiler.Sample(FrameProfiler.PresentPhase)) + graphicsDevice.SwapBuffers(); + + // The 3D scene goes to the GPU here, AFTER the present and the device wait above, so it runs + // while the CPU pumps events and builds the next frame. Recorded during Update; see + // View3D.SubmitScene and VulkanRenderer.SubmitFrame. + foreach (var view in openFrames.OfType()) + view.SubmitScene(); } protected virtual void OnClose() { } @@ -505,9 +620,10 @@ private void OnResize(int width, int height) { imGuiController.Resize(width, height); GraphicsDevice.MainSwapchain.Resize((uint)width, (uint)height); + // The blit caches a resource set per texture view, and Resize replaces the view it was built + // from, so the old one has to be dropped before it is freed underneath the cache. + FullScreenRenderer.Invalidate(FullScreenTexture.ResolveTextureView); FullScreenTexture.Resize((uint)width, (uint)height); - FinalFullScreenTexture.Dispose(); - FinalFullScreenTexture = new Texture2D(GraphicsDevice, new Image(width, height), false); } public int GetTargetFPS() => (int)(1.0 / fixedUpdateTimeStep); @@ -517,14 +633,13 @@ public void SetTargetFPS(int fps) fixedFrameRate = fps == 0 ? double.MaxValue : 1.0 / fps; } - protected override void Dispose(bool disposing) + public void Dispose() { - if (disposing) - { - GlobalResource.Destroy(); - Input.Destroy(); - MainWindow.Dispose(); - GraphicsDevice.Dispose(); - } + GC.SuppressFinalize(this); + FullScreenRenderer?.Dispose(); + FullScreenTexture?.Dispose(); + Input.Destroy(); + MainWindow.Dispose(); + GraphicsDevice.Dispose(); } } diff --git a/ReLunacy/Global.cs b/ReLunacy/Global.cs index d071e30..e7dc92a 100644 --- a/ReLunacy/Global.cs +++ b/ReLunacy/Global.cs @@ -12,10 +12,6 @@ global using Newtonsoft; global using Newtonsoft.Json; -global using Bliss; -global using Bliss.CSharp; -global using Bliss.CSharp.Mathematics; - global using Hexa.NET.ImGui; global using ReLunacy; diff --git a/ReLunacy/Locales/en.json b/ReLunacy/Locales/en.json index d4d1371..fa69a26 100644 --- a/ReLunacy/Locales/en.json +++ b/ReLunacy/Locales/en.json @@ -3,6 +3,7 @@ "strings": { "GUI_Common_AdvancedCollapsed": "Advanced", "GUI_Common_CancelWord": "Cancel", + "GUI_Common_Reset": "Reset", "GUI_Common_CloseWord": "Close", "GUI_Common_FilterAll": "All", "GUI_Common_FilterUsed": "Used", @@ -35,6 +36,8 @@ "GUI_Frame_AssetViewer_Tab": "Assets", "GUI_Frame_AssetViewer_Shaders": "Shaders used", "GUI_Frame_AssetViewer_SelectedMeshTitle": "Selected mesh", + "GUI_Frame_AssetViewer_ClipControls": "Camera clipping", + "GUI_Frame_AssetViewer_FarClip": "Far clip: %.1f", "GUI_Frame_AssetViewer_SelectedMeshHint": "Click a bangle/mesh in the preview above to inspect it.", "GUI_Frame_AssetViewer_SelectedMeshStale": "This bangle/mesh no longer exists on the current selection.", "GUI_Frame_AssetViewer_VertexEditMode": "Vertex edit mode", @@ -159,6 +162,8 @@ "GUI_Frame_InstanceInspector_Vertices": "Vertices count", "GUI_Frame_InstanceInspector_ViewToEntity": "Teleport to Entity", "GUI_Frame_InstanceInspector_WaitingForSelection": "Select an entity...", + "GUI_Frame_InstanceInspector_CullDistance": "Cull Distance", + "GUI_Frame_InstanceInspector_UpdateDistance": "Update Distance", "GUI_Frame_LevelExportModal_Title": "Export Level", "GUI_Frame_LevelExportModal_Mobys": "Mobys", "GUI_Frame_LevelExportModal_Ties": "Ties", @@ -177,8 +182,8 @@ "GUI_Frame_ShaderBrowser_AlphaSection": "Alpha / Blend", "GUI_Frame_ShaderBrowser_RenderingMode": "renderingMode byte: {0}", "GUI_Frame_ShaderBrowser_FilterRenderMode": "Filter by render mode", + "GUI_Frame_ShaderBrowser_FilterUsage": "Filter by usage", "GUI_Frame_ShaderBrowser_AlphaClip": "alphaClip: {0}", - "GUI_Frame_ShaderBrowser_AlphaKillCandidate": "AlphaKillCandidate (unverified): {0}", "GUI_Frame_ShaderBrowser_Unknown": "UNKNOWN", "GUI_Frame_ShaderBrowser_TexturesSection": "Textures", "GUI_Frame_ShaderBrowser_Albedo": "Albedo", @@ -192,6 +197,15 @@ "GUI_Frame_ShaderBrowser_FindUsages": "Find usages", "GUI_Frame_LoadingModal": "Loading...", "GUI_Frame_Logs": "Logs", + "GUI_Frame_Profiler": "Frame Profiler", + "GUI_Frame_Profiler_FrameTotal": "Frame: {0:0.00} ms ({1:0} FPS)", + "GUI_Frame_Profiler_Collecting": "Collecting samples...", + "GUI_Frame_Profiler_Phase": "Phase", + "GUI_Frame_Profiler_Last": "Last (ms)", + "GUI_Frame_Profiler_Avg": "Avg (ms)", + "GUI_Frame_Profiler_Percent": "% of frame", + "GUI_Frame_Profiler_Counters": "Counters", + "GUI_Frame_Profiler_GpuNote": "\"GPU Wait\" is the per-frame WaitForIdle stall - the CPU blocked on the GPU. It stands in for GPU cost since Veldrith exposes no GPU timestamp query.", "GUI_Frame_OpenLevel": "Open level", "GUI_Frame_OpenLevel_LevelPath": "Level Path", "GUI_Frame_OpenLevel_PasteClipboard": "Paste", @@ -209,10 +223,15 @@ "GUI_Frame_TextureExplorer_Preview_TextureBufferSize": "Buffer Size", "GUI_Frame_TextureExplorer_Preview_TextureCompressionType": "Compression Format", "GUI_Frame_TextureExplorer_Preview_TextureDimensions": "Dimensions", + "GUI_Frame_TextureExplorer_Preview_TextureIndex": "Index", "GUI_Frame_TextureExplorer_Preview_TextureName": "Name", "GUI_Frame_TextureExplorer_Preview_TextureSizeOnDisk": "Size on Disk", "GUI_Frame_TextureExplorer_SearchHint": "Search among {0} textures...", "GUI_Frame_TextureExplorer_SearchLabel": "Search", + "GUI_Frame_TextureExplorer_Cubemaps": "Cubemaps ({0})", + "GUI_Frame_TextureExplorer_Cubemap_Exposure": "Exposure", + "GUI_Frame_TextureExplorer_Cubemap_Info": "Faces: {0}px x {1}", + "GUI_Frame_TextureExplorer_Cubemap_ExportCross": "Export cross (PNG)", "GUI_Frame_UpdateInfo_Title": "Update Available!", "GUI_Frame_UpdateInfo_StableAvailable": "A new update for ReLunacy is available!", "GUI_Frame_UpdateInfo_NightlyAvailable": "A newer nightly build of ReLunacy is available!", @@ -224,8 +243,11 @@ "GUI_Frame_UpdateInfo_HoursAgo": "{0} hour(s) ago", "GUI_Frame_UpdateInfo_MinutesAgo": "{0} minute(s) ago", "GUI_Frame_UpdateInfo_Download": "Download update", + "GUI_Frame_UpdateInfo_Changelog": "Changelog", + "GUI_Frame_UpdateInfo_Commits": "Commits in this update ({0})", "GUI_Frame_View3D": "3D View", "GUI_LoadLevelModal_Title": "Loading level", + "GUI_LoadLevelModal_UploadingTextures": "Uploading textures to GPU", "GUI_MenuItem_CheckUpdates": "Check for updates", "GUI_MenuItem_CloseLevel": "Close level", "GUI_MenuItem_DeselectObjects": "Deselect object(s)", @@ -234,8 +256,36 @@ "GUI_MenuItem_RenderBoundingSpheres": "Bounding Spheres", "GUI_MenuItem_RenderMobys": "Mobys", "GUI_MenuItem_MobyDistanceCulling": "Moby Distance Culling", + "GUI_Frame_LevelData": "Level Data", + "GUI_Frame_LevelData_NoLevel": "No level loaded.", + "GUI_Frame_LevelData_Overview": "Overview", + "GUI_Frame_LevelData_Lighting": "Lighting", + "GUI_Frame_LevelData_Engine": "Engine", + "GUI_Frame_LevelData_MobyInstances": "Moby instances", + "GUI_Frame_LevelData_MobyAssets": "Moby assets", + "GUI_Frame_LevelData_TieInstances": "Tie instances", + "GUI_Frame_LevelData_TieAssets": "Tie assets", + "GUI_Frame_LevelData_UFrags": "UFrags", + "GUI_Frame_LevelData_Zones": "Zones", + "GUI_Frame_LevelData_Volumes": "Volumes", + "GUI_Frame_LevelData_Foliage": "Foliage placements", + "GUI_Frame_LevelData_Cubemaps": "Cubemaps", + "GUI_Frame_LevelData_Textures": "Textures", + "GUI_Frame_LevelData_Shaders": "Shaders", + "GUI_Frame_LevelData_Lightmaps": "Zone lightmaps", + "GUI_Frame_LevelData_Directionals": "Zone directionals", + "GUI_Frame_LevelData_NoLightEnv": "This level ships no analytic lighting environment (section 0x8b00).", + "GUI_Frame_LevelData_Ambient": "Ambient", + "GUI_Frame_LevelData_Light": "Directional light {0}", + "GUI_Frame_LevelData_LightColour": "Colour", + "GUI_Frame_LevelData_LightDir": "Direction", + "GUI_MenuItem_ReflectionDebug": "Reflection Debug View", + "GUI_MenuItem_ReflectionIntensity": "Reflection Intensity", + "GUI_MenuItem_ReflectionBase": "Reflectivity (Fresnel F0)", + "GUI_MenuItem_BakedAmbient": "Baked ambient floor", "GUI_MenuItem_RenderTies": "Ties", "GUI_MenuItem_RenderUFrags": "UFrags", + "GUI_MenuItem_RenderFoliage": "Foliage", "GUI_MenuItem_RenderVolumes": "Volumes", "GUI_MenuItem_ShowOverlay": "Show Overlay", "GUI_Menu_About": "About", @@ -273,6 +323,8 @@ "GUI_Overlay_VramUsage": "VRAM Usage", "GUI_TransformTools_Rotation": "Rotation", "GUI_TransformTools_Scale": "Scale", - "GUI_TransformTools_Translation": "Translation" + "GUI_TransformTools_Translation": "Translation", + "GUI_Frame_AssetViewer_FoliageTab": "Foliage", + "GUI_Frame_AssetViewer_NoFoliage": "This level has no foliage (old engine only)." } -} +} \ No newline at end of file diff --git a/ReLunacy/NightlyBuildInfo.cs b/ReLunacy/NightlyBuildInfo.cs index 3b30275..486ed31 100644 --- a/ReLunacy/NightlyBuildInfo.cs +++ b/ReLunacy/NightlyBuildInfo.cs @@ -3,7 +3,7 @@ namespace ReLunacy; // Rewritten by .github/workflows/nightly.yml right before a nightly build compiles, stamping in // the short commit hash and build date baked into that build's release asset filename (see // UpdateChecker.CheckNightly, which extracts the same info back out of the filename to compare). -// Left null here for every other build (local dev, stable releases) — nightly-update comparisons +// Left null here for every other build (local dev, stable releases) - nightly-update comparisons // only make sense when the running binary actually knows which nightly build it is. public static class NightlyBuildInfo { diff --git a/ReLunacy/ProgramInfo.cs b/ReLunacy/ProgramInfo.cs index 746d925..1a737d3 100644 --- a/ReLunacy/ProgramInfo.cs +++ b/ReLunacy/ProgramInfo.cs @@ -10,6 +10,6 @@ public static class ProgramInfo { public const string Name = "ReLunacy"; public const string DisplayName = "ReLunacy"; - public const string Version = "0.04.1"; + public const string Version = "0.05"; public const string GithubURL = "https://github.com/VELD-Dev/ReLunacy/"; } diff --git a/ReLunacy/ReLunacy.csproj b/ReLunacy/ReLunacy.csproj index 291f235..6e00e45 100644 --- a/ReLunacy/ReLunacy.csproj +++ b/ReLunacy/ReLunacy.csproj @@ -42,11 +42,21 @@ - + + + + + + + + + @@ -54,7 +64,7 @@ - diff --git a/ReLunacy/Utility/AxisGizmoRenderer.cs b/ReLunacy/Utility/AxisGizmoRenderer.cs index cb0eb66..19e58ea 100644 --- a/ReLunacy/Utility/AxisGizmoRenderer.cs +++ b/ReLunacy/Utility/AxisGizmoRenderer.cs @@ -1,11 +1,10 @@ using System.Numerics; -using Bliss.CSharp.Camera.Dim3; namespace ReLunacy.Utility; /// /// Small always-visible orientation indicator drawn in a corner of a 3D viewport: three colored -/// axes (X=red, Y=green, Z=blue — the common Unity/Blender/Godot convention) projected using the +/// axes (X=red, Y=green, Z=blue - the common Unity/Blender/Godot convention) projected using the /// camera's own right/up basis. Purely informational, unlike a full interactive view-cube. /// public static class AxisGizmoRenderer @@ -17,14 +16,14 @@ private static readonly (Vector3 Axis, string Label, Vector4 Color)[] Axes = (Vector3.UnitZ, "Z", new Vector4(0.30f, 0.50f, 0.95f, 1f)), ]; - public static void Draw(Cam3D camera, Vector2 center, float radius) + public static void Draw(Engine.Rendering.EditorCamera camera, Vector2 center, float radius) { var drawList = ImGui.GetWindowDrawList(); Vector3 forward = camera.GetForward(); Vector3 right = Vector3.Normalize(camera.GetRight()); // GetRight() = Cross(forward, world Up); re-orthogonalize against forward to get the - // camera's true screen-space up — Cam3D.Up itself stays world-Y and doesn't tilt with + // camera's true screen-space up: the camera's Up itself stays world-Y and doesn't tilt with // pitch, so using it directly here would make the gizmo drift out of sync while looking // up/down. Vector3 up = Vector3.Normalize(Vector3.Cross(right, forward)); @@ -40,7 +39,7 @@ public static void Draw(Cam3D camera, Vector2 center, float radius) float depth = Vector3.Dot(axis, forward); var tip = center + new Vector2(sx, sy) * radius; - // depth < 0 means this axis points toward the camera ("out of the screen") — drawn + // depth < 0 means this axis points toward the camera ("out of the screen") - drawn // brighter than one receding into it, for a cheap sense of depth without real 3D. float shade = depth < 0f ? 1f : 0.55f; uint col = ImGui.GetColorU32(color * new Vector4(shade, shade, shade, 1f)); diff --git a/ReLunacy/Utility/EditorSettings.cs b/ReLunacy/Utility/EditorSettings.cs index a0b0912..8b76028 100644 --- a/ReLunacy/Utility/EditorSettings.cs +++ b/ReLunacy/Utility/EditorSettings.cs @@ -46,35 +46,33 @@ public class EditorSettings public Vector4 SelectionOutlineColor; internal LunaLog.LogLevel LogLevel; public Dictionary CustomShaders = []; - public bool LegacyRenderingMode; - // Opt-in only: SelectionOutlineRenderer's class comment documents that both winding-based and - // normal-based backface techniques were tried for the selection outline and both broke — + // Opt-in only: the outline's history documents that both winding-based and + // normal-based backface techniques were tried for the selection outline and both broke - // triangle winding in these source assets isn't reliably consistent (sometimes not even within // a single mesh), which is why AssetManager hardcodes CULL_NONE by default. This flag exists so // culling can be flipped on live, per-session, to see how bad it actually is on real data rather - // than assuming — not a confirmed-safe rendering mode. + // than assuming - not a confirmed-safe rendering mode. public bool BackfaceCulling; // See UpdateChecker: Stable checks GitHub's normal "latest release"; Nightly checks the // rolling "nightly" tag release .github/workflows/nightly.yml keeps updated on every push to - // the nightly branch. Independent of which build the user is actually running — someone on a + // the nightly branch. Independent of which build the user is actually running - someone on a // stable build can still opt into nightly update notifications and vice versa. public UpdateChannel UpdateChannel; - // First real lighting pass for the live renderer (see LitModelShaderSource) — everything else + // First real lighting pass for the live renderer (see LitModelShaderSource) - everything else // is unlit. Opt-in default off, same "experimental until proven" reasoning as BackfaceCulling // above, since this is genuinely new/unverified rendering code, not a rebuild of something // already trusted. public bool EnableLighting; - public Vector3 LightDirection; - public Vector3 LightColor; - public float LightAmbient; - // Scene-wide Phong specular exponent — a scene setting rather than per-material data because - // this game's texture format carries no per-pixel specular-power channel (see - // LitModelShaderSource's header comment). - public float LightSpecularPower; // Scene-wide default texture filtering for the 3D view (AssetManager also supports per-texture - // overrides for future use — see AssetManager.SetTextureFiltering(textureId, filtering)). + // overrides for future use - see AssetManager.SetTextureFiltering(textureId, filtering)). public ReLunacy.Engine.Rendering.TextureFiltering TextureFiltering; + // Far clip for the ASSET VIEWER's preview camera only (the 3D view has its own, RenderDistance). + // Assets are previewed at wildly different scales - a UFrag is drawn at 1/256 while a moby is + // unit-ish - so one hardcoded far plane clipped some of them; this is adjustable from the overlay + // toolbar over the preview itself. + public float AssetViewerFarPlane; + [JsonIgnore] public float CamFOVRad => CamFOV * (MathF.PI / 180f); @@ -116,16 +114,12 @@ public EditorSettings() VolumeColor = new Vector4(1f, 1f, 0f, 1f); VolumeSelectedColor = new Vector4(1f, 1f, 1f, 1f); SelectionOutlineColor = new Vector4(1f, 0.65f, 0f, 1f); - LegacyRenderingMode = false; BackfaceCulling = false; UpdateChannel = UpdateChannel.Stable; EnableLighting = false; - LightDirection = new Vector3(-0.4f, -0.8f, 0.3f); - LightColor = Vector3.One; - LightAmbient = 0.15f; - LightSpecularPower = 32f; + AssetViewerFarPlane = 100f; // Bilinear by default: it's what the game itself does on PS3, and the reason this - // setting exists at all — Point remains selectable for pixel-peeping raw texel data. + // setting exists at all - Point remains selectable for pixel-peeping raw texel data. TextureFiltering = ReLunacy.Engine.Rendering.TextureFiltering.Bilinear; #if DEBUG LogLevel = LunaLog.LogLevel.Debug; diff --git a/ReLunacy/Utility/ExportRunner.cs b/ReLunacy/Utility/ExportRunner.cs index b2e718e..2d95d02 100644 --- a/ReLunacy/Utility/ExportRunner.cs +++ b/ReLunacy/Utility/ExportRunner.cs @@ -9,8 +9,8 @@ namespace ReLunacy.Utility; /// export doesn't freeze the UI, then reports success/failure via an ExportResultModal. /// /// `action` only ever touches the progress modal through UpdateProgress (which locks internally) -/// and otherwise reports back through LunaWindow.QueueExportCompletion — a ConcurrentQueue drained -/// on the main thread — rather than mutating openFrames itself, same rule LoadLevelDataAsync +/// and otherwise reports back through LunaWindow.QueueExportCompletion - a ConcurrentQueue drained +/// on the main thread - rather than mutating openFrames itself, same rule LoadLevelDataAsync /// follows for the same reason (openFrames is a plain List<Frame>, not thread-safe against /// concurrent enumeration during ImGui rendering). public static class ExportRunner diff --git a/ReLunacy/Utility/GizmoController.cs b/ReLunacy/Utility/GizmoController.cs index 744c005..7457ef1 100644 --- a/ReLunacy/Utility/GizmoController.cs +++ b/ReLunacy/Utility/GizmoController.cs @@ -1,6 +1,5 @@ using System.Numerics; -using Bliss.CSharp.Camera.Dim3; -using Bliss.CSharp.Transformations; +using ReLunacy.Engine.Rendering.Resources; using Hexa.NET.ImGuizmo; using ReLunacy.Engine.Scene; @@ -16,7 +15,7 @@ public class GizmoController /// /// True while the cursor is over a gizmo handle. IsUsingAny() lags a frame behind an initial /// click (it wants a drag delta first), so on the very first click-down on a handle it would - /// still read false — checking IsOver too catches that frame so the click isn't mistaken for + /// still read false - checking IsOver too catches that frame so the click isn't mistaken for /// a pick request. Gated on _manipulatedThisFrame since IsOver() reflects stale state from /// whatever the last Manipulate() call drew when there's no selection to manipulate now. /// @@ -34,7 +33,7 @@ private void EnsureInitialized() ImGuizmo.SetOrthographic(false); } - public void Render(Cam3D camera, Entity? entity, Vector2 viewportPos, Vector2 viewportSize) + public void Render(Engine.Rendering.EditorCamera camera, Entity? entity, Vector2 viewportPos, Vector2 viewportSize) { EnsureInitialized(); ImGuizmo.BeginFrame(); diff --git a/ReLunacy/Utility/Host/EditorWindow.cs b/ReLunacy/Utility/Host/EditorWindow.cs new file mode 100644 index 0000000..83602db --- /dev/null +++ b/ReLunacy/Utility/Host/EditorWindow.cs @@ -0,0 +1,185 @@ +using SDL3; +using Veldrith; + +namespace ReLunacy.Utility; + +/// The application's OS window, and the graphics device drawing into it. +/// +/// A thin wrapper over SDL3: create the window, pump its event queue (handing every event to +/// ), and hand Veldrith the native handles it needs for a swapchain. +public sealed class EditorWindow : IDisposable +{ + private nint _handle; + + /// False once the window has been closed, which is what ends the main loop. + public bool Exists { get; private set; } + + public event Action? Resized; + + public nint Handle => _handle; + + private EditorWindow(nint handle) + { + _handle = handle; + Exists = true; + } + + /// Opens the window and creates a graphics device with a swapchain onto it. + /// The requested backend is not available here. + public static EditorWindow Create( + int width, int height, string title, GraphicsDeviceOptions options, + GraphicsBackend preferredBackend, out GraphicsDevice graphicsDevice) + { + if (!GraphicsDevice.IsBackendSupported(preferredBackend)) + throw new PlatformNotSupportedException($"The graphics backend [{preferredBackend}] is not supported on this platform."); + + if (!SDL.Init(SDL.InitFlags.Video | SDL.InitFlags.Events)) + throw new InvalidOperationException($"SDL_Init failed: {SDL.GetError()}"); + + // The backend flag has to be on the window at CREATION time: SDL picks the surface type then, + // and a window made without it cannot be handed to Vulkan afterwards. + // Deliberately NOT HighPixelDensity. With it, the drawable is larger than the window in desktop + // coordinates, while SDL keeps reporting the cursor in the smaller one: every framebuffer here + // is sized in pixels and every hit test compares against ImGui's display size, so the two spaces + // have to stay the same one. Supporting a scaled display means converting at the input boundary, + // not just asking for the bigger surface. + var flags = SDL.WindowFlags.Resizable | preferredBackend switch + { + GraphicsBackend.Vulkan => SDL.WindowFlags.Vulkan, + GraphicsBackend.Metal => SDL.WindowFlags.Metal, + _ => 0, + }; + + nint handle = SDL.CreateWindow(title, width, height, flags); + if (handle == nint.Zero) + throw new InvalidOperationException($"SDL_CreateWindow failed: {SDL.GetError()}"); + + var window = new EditorWindow(handle); + graphicsDevice = window.CreateGraphicsDevice(options, preferredBackend); + return window; + } + + private GraphicsDevice CreateGraphicsDevice(GraphicsDeviceOptions options, GraphicsBackend backend) + { + var (w, h) = GetSizeInPixels(); + var description = new SwapchainDescription( + CreateSwapchainSource(), (uint)w, (uint)h, + options.SwapchainDepthFormat, options.SyncToVerticalBlank, options.SwapchainSrgbFormat); + + return backend switch + { + GraphicsBackend.Vulkan => GraphicsDevice.CreateVulkan(options, description), + GraphicsBackend.Direct3D12 => GraphicsDevice.CreateD3D12(options, description), + GraphicsBackend.Metal => GraphicsDevice.CreateMetal(options, description), + _ => throw new VeldridException($"Invalid GraphicsBackend: [{backend}]"), + }; + } + + /// The platform-native handles behind this window, in the shape Veldrith wants. + /// + /// SDL exposes them as window "properties" rather than as typed accessors, which is why this reads + /// like a lookup table. Wayland is checked before X11 because a session running XWayland reports + /// both, and the native one is the right answer. + private SwapchainSource CreateSwapchainSource() + { + uint props = SDL.GetWindowProperties(_handle); + + if (OperatingSystem.IsWindows()) + { + nint hwnd = SDL.GetPointerProperty(props, SDL.Props.WindowWin32HWNDPointer, nint.Zero); + nint hinstance = SDL.GetPointerProperty(props, SDL.Props.WindowWin32InstancePointer, nint.Zero); + if (hwnd != nint.Zero) + return SwapchainSource.CreateWin32(hwnd, hinstance); + } + else if (OperatingSystem.IsMacOS()) + { + nint nsWindow = SDL.GetPointerProperty(props, SDL.Props.WindowCocoaWindowPointer, nint.Zero); + if (nsWindow != nint.Zero) + return SwapchainSource.CreateNSWindow(nsWindow); + } + else + { + nint wlDisplay = SDL.GetPointerProperty(props, SDL.Props.WindowWaylandDisplayPointer, nint.Zero); + nint wlSurface = SDL.GetPointerProperty(props, SDL.Props.WindowWaylandSurfacePointer, nint.Zero); + if (wlDisplay != nint.Zero && wlSurface != nint.Zero) + return SwapchainSource.CreateWayland(wlDisplay, wlSurface); + + nint x11Display = SDL.GetPointerProperty(props, SDL.Props.WindowX11DisplayPointer, nint.Zero); + long x11Window = SDL.GetNumberProperty(props, SDL.Props.WindowX11WindowNumber, 0); + if (x11Display != nint.Zero && x11Window != 0) + return SwapchainSource.CreateXlib(x11Display, (nint)x11Window); + } + + throw new PlatformNotSupportedException("Could not find a native window handle SDL and Veldrith agree on."); + } + + /// Size of the drawable surface, NOT of the window in desktop coordinates. The two differ + /// on a scaled display, and every framebuffer here is sized in real pixels. + public (int Width, int Height) GetSizeInPixels() + { + SDL.GetWindowSizeInPixels(_handle, out int w, out int h); + return (Math.Max(1, w), Math.Max(1, h)); + } + + public int GetWidth() => GetSizeInPixels().Width; + public int GetHeight() => GetSizeInPixels().Height; + + public void SetTitle(string title) => SDL.SetWindowTitle(_handle, title); + + /// Sets the taskbar/titlebar icon. SDL copies the pixels into its own surface, so the + /// caller's image can be released straight afterwards. + public unsafe void SetIcon(Engine.Rendering.Resources.Image icon) + { + fixed (byte* pixels = icon.Data) + { + nint surface = SDL.CreateSurfaceFrom( + icon.Width, icon.Height, SDL.PixelFormat.ABGR8888, (nint)pixels, icon.Width * 4); + if (surface == nint.Zero) return; + SDL.SetWindowIcon(_handle, surface); + SDL.DestroySurface(surface); + } + } + + /// Drains SDL's event queue into and this window's own state. Call once + /// per frame, before anything reads input. + public void PumpEvents() + { + SDL.PumpEvents(); + while (SDL.PollEvent(out var e)) + { + switch ((SDL.EventType)e.Type) + { + case SDL.EventType.Quit: + case SDL.EventType.WindowCloseRequested: + Exists = false; + break; + + // Pixel size, not window size: on a scaled display only this one tracks the framebuffer, + // and a WindowResized alone would leave every target sized for the wrong surface. + case SDL.EventType.WindowPixelSizeChanged: + Resized?.Invoke(); + break; + + // Focus loss has to clear the key state. The OS stops delivering key-up events to an + // unfocused window, so a key held while alt-tabbing away would otherwise stay down + // forever. + case SDL.EventType.WindowFocusLost: + Input.ClearState(); + break; + + default: + Input.ProcessEvent(e); + break; + } + } + } + + public void Dispose() + { + if (_handle == nint.Zero) return; + SDL.DestroyWindow(_handle); + _handle = nint.Zero; + Exists = false; + SDL.Quit(); + } +} diff --git a/ReLunacy/Utility/Host/FullscreenBlit.cs b/ReLunacy/Utility/Host/FullscreenBlit.cs new file mode 100644 index 0000000..53262c2 --- /dev/null +++ b/ReLunacy/Utility/Host/FullscreenBlit.cs @@ -0,0 +1,126 @@ +using System.Text; +using Veldrith; +using Veldrith.SPIRV; + +namespace ReLunacy.Utility; + +/// Copies one texture over the whole of the current framebuffer. +/// +/// The last step of every frame: the UI is composited offscreen (see ) +/// and this puts the result on the swapchain. +public sealed class FullscreenBlit : IDisposable +{ + // A single oversized triangle rather than two triangles for a quad: it covers the framebuffer with + // no seam down the diagonal, and needs no vertex buffer at all because the three corners are + // derived from the vertex index. + private const string VertexShader = """ + #version 450 + + layout(location = 0) out vec2 fsUV; + + void main() + { + vec2 corner = vec2((gl_VertexIndex << 1) & 2, gl_VertexIndex & 2); + gl_Position = vec4(corner * 2.0 - 1.0, 0.0, 1.0); + // Clip y = +1 is the top row of the framebuffer here (the device is configured for the + // standard clip-space Y direction), while texture v = 0 is the top row of the source, so + // the vertical axis has to be flipped or the whole screen presents upside down. + fsUV = vec2(corner.x, 1.0 - corner.y); + } + """; + + private const string FragmentShader = """ + #version 450 + + layout(location = 0) in vec2 fsUV; + layout(location = 0) out vec4 outColor; + + layout(set = 0, binding = 0) uniform texture2D SourceTexture; + layout(set = 0, binding = 1) uniform sampler SourceSampler; + + void main() + { + outColor = texture(sampler2D(SourceTexture, SourceSampler), fsUV); + } + """; + + private readonly GraphicsDevice _graphicsDevice; + private readonly Shader[] _shaders; + private readonly ResourceLayout _layout; + private readonly Dictionary _resourceSets = []; + private readonly Dictionary _pipelines = []; + + public FullscreenBlit(GraphicsDevice graphicsDevice) + { + _graphicsDevice = graphicsDevice; + var factory = graphicsDevice.ResourceFactory; + + _shaders = factory.CreateFromSpirv( + new ShaderDescription(ShaderStages.Vertex, Encoding.UTF8.GetBytes(VertexShader), "main"), + new ShaderDescription(ShaderStages.Fragment, Encoding.UTF8.GetBytes(FragmentShader), "main"), + new CrossCompileOptions()); + + _layout = factory.CreateResourceLayout(new ResourceLayoutDescription( + new ResourceLayoutElementDescription("SourceTexture", ResourceKind.TextureReadOnly, ShaderStages.Fragment), + new ResourceLayoutElementDescription("SourceSampler", ResourceKind.Sampler, ShaderStages.Fragment))); + } + + /// Cached per output format. The swapchain's format is fixed in practice, so this holds one + /// pipeline, but the key keeps a second target (a different sample count or colour format) from + /// silently reusing an incompatible one. + private Pipeline GetPipeline(OutputDescription output) + { + if (_pipelines.TryGetValue(output, out var cached)) return cached; + + var description = new GraphicsPipelineDescription( + BlendStateDescription.SINGLE_OVERRIDE_BLEND, + DepthStencilStateDescription.DISABLED, + RasterizerStateDescription.CULL_NONE, + PrimitiveTopology.TriangleList, + new ShaderSetDescription([], _shaders), + [_layout], + output); + + var pipeline = _graphicsDevice.ResourceFactory.CreateGraphicsPipeline(ref description); + _pipelines[output] = pipeline; + return pipeline; + } + + private ResourceSet GetResourceSet(TextureView source) + { + if (_resourceSets.TryGetValue(source, out var cached)) return cached; + + var set = _graphicsDevice.ResourceFactory.CreateResourceSet( + new ResourceSetDescription(_layout, source, _graphicsDevice.PointSampler)); + _resourceSets[source] = set; + return set; + } + + /// Draws across the framebuffer already set on + /// . + public void Draw(CommandList commandList, TextureView source, OutputDescription output) + { + commandList.SetPipeline(GetPipeline(output)); + commandList.SetGraphicsResourceSet(0, GetResourceSet(source)); + commandList.Draw(3); + } + + /// Drops the cached resource set for a texture view about to be destroyed. A resize + /// recreates the render target's views, and a set still pointing at a freed one is a use-after-free + /// the next time that frame draws. + public void Invalidate(TextureView source) + { + if (!_resourceSets.Remove(source, out var set)) return; + set.Dispose(); + } + + public void Dispose() + { + foreach (var set in _resourceSets.Values) set.Dispose(); + _resourceSets.Clear(); + foreach (var pipeline in _pipelines.Values) pipeline.Dispose(); + _pipelines.Clear(); + _layout.Dispose(); + foreach (var shader in _shaders) shader.Dispose(); + } +} diff --git a/ReLunacy/Utility/Host/Input.cs b/ReLunacy/Utility/Host/Input.cs new file mode 100644 index 0000000..58fafee --- /dev/null +++ b/ReLunacy/Utility/Host/Input.cs @@ -0,0 +1,184 @@ +using System.Numerics; +using System.Runtime.InteropServices; +using SDL3; + +namespace ReLunacy.Utility; + +/// Keyboard, mouse and text input for the frame currently being built. +/// +/// Static because there is exactly one window and one cursor. +/// feeds it; and bracket the frame so the edge-triggered queries +/// (IsKeyPressed, IsMouseButtonPressed) can compare against the previous frame. +public static class Input +{ + // SDL's scancode space. The array is indexed by scancode directly, which is why KeyboardKey's + // values are scancodes. + private const int KeyCount = 512; + + private static nint _window; + + private static readonly bool[] _keysDown = new bool[KeyCount]; + private static readonly bool[] _keysDownLast = new bool[KeyCount]; + private static readonly bool[] _mouseDown = new bool[8]; + private static readonly bool[] _mouseDownLast = new bool[8]; + + private static Vector2 _mousePosition; + private static Vector2 _mouseDelta; + private static Vector2 _scrollDelta; + private static string _typedText = string.Empty; + private static bool _relativeMouseMode; + + public static void Init(EditorWindow window) => _window = window.Handle; + + public static void Destroy() + { + _window = nint.Zero; + ClearState(); + } + + /// Drops every held key and button. Used on focus loss, where the OS stops sending the + /// matching key-up events. + public static void ClearState() + { + Array.Clear(_keysDown); + Array.Clear(_keysDownLast); + Array.Clear(_mouseDown); + Array.Clear(_mouseDownLast); + _mouseDelta = Vector2.Zero; + _scrollDelta = Vector2.Zero; + } + + /// Snapshots the previous frame's key/button state, before this frame's events land. + /// Everything edge-triggered is a comparison against that snapshot. + public static void Begin() + { + Array.Copy(_keysDown, _keysDownLast, KeyCount); + Array.Copy(_mouseDown, _mouseDownLast, _mouseDown.Length); + } + + /// Clears the per-frame accumulators. Held state survives; deltas and typed text do not. + public static void End() + { + _mouseDelta = Vector2.Zero; + _scrollDelta = Vector2.Zero; + _typedText = string.Empty; + } + + internal static void ProcessEvent(SDL.Event e) + { + switch ((SDL.EventType)e.Type) + { + case SDL.EventType.KeyDown: + // Auto-repeat is deliberately not recorded: a repeat is not a fresh press, and letting + // it through would make IsKeyPressed fire over and over while a key is simply held. + if (!e.Key.Repeat) SetKey(e.Key.Scancode, true); + break; + + case SDL.EventType.KeyUp: + SetKey(e.Key.Scancode, false); + break; + + case SDL.EventType.MouseButtonDown: + case SDL.EventType.MouseButtonUp: + SetMouseButton(e.Button.Button, e.Button.Down); + // The button event carries its own position. Taking it means a click is registered at + // where the click happened, even if no motion event preceded it that frame. + _mousePosition = new Vector2(e.Button.X, e.Button.Y); + break; + + case SDL.EventType.MouseMotion: + _mousePosition = new Vector2(e.Motion.X, e.Motion.Y); + // Accumulated, not assigned: several motion events can arrive in one frame, and in + // relative mouse mode the deltas are the only thing that carries the movement at all. + _mouseDelta += new Vector2(e.Motion.XRel, e.Motion.YRel); + break; + + case SDL.EventType.MouseWheel: + // Flipped is SDL telling us the platform already applied natural scrolling to the + // values; undoing it keeps a wheel-up here meaning the same thing everywhere. + float sign = e.Wheel.Direction == SDL.MouseWheelDirection.Flipped ? -1f : 1f; + _scrollDelta += new Vector2(e.Wheel.X, e.Wheel.Y) * sign; + break; + + case SDL.EventType.TextInput: + _typedText += ReadUtf8(e.Text.Text); + break; + } + } + + private static void SetKey(SDL.Scancode scancode, bool down) + { + int index = (int)scancode; + if ((uint)index < KeyCount) _keysDown[index] = down; + } + + private static void SetMouseButton(byte button, bool down) + { + if (button < _mouseDown.Length) _mouseDown[button] = down; + } + + private static string ReadUtf8(nint utf8) => + utf8 == nint.Zero ? string.Empty : Marshal.PtrToStringUTF8(utf8) ?? string.Empty; + + public static bool IsKeyDown(KeyboardKey key) => _keysDown[(int)key]; + public static bool IsKeyUp(KeyboardKey key) => !_keysDown[(int)key]; + public static bool IsKeyPressed(KeyboardKey key) => _keysDown[(int)key] && !_keysDownLast[(int)key]; + public static bool IsKeyReleased(KeyboardKey key) => !_keysDown[(int)key] && _keysDownLast[(int)key]; + + public static bool IsMouseButtonDown(MouseButton button) => _mouseDown[(int)button]; + public static bool IsMouseButtonUp(MouseButton button) => !_mouseDown[(int)button]; + public static bool IsMouseButtonPressed(MouseButton button) => _mouseDown[(int)button] && !_mouseDownLast[(int)button]; + public static bool IsMouseButtonReleased(MouseButton button) => !_mouseDown[(int)button] && _mouseDownLast[(int)button]; + + /// Cursor position in window coordinates. + public static Vector2 GetMousePosition() => _mousePosition; + + /// Movement since the last frame. This is the ONLY meaningful reading during a + /// relative-mouse-mode drag, where the cursor itself does not move. + public static Vector2 GetMouseDelta() => _mouseDelta; + + public static bool IsMouseScrolling(out Vector2 delta) + { + delta = _scrollDelta; + return delta != Vector2.Zero; + } + + public static void SetMousePosition(Vector2 position) + { + SDL.WarpMouseInWindow(_window, position.X, position.Y); + _mousePosition = position; + } + + /// Hides the cursor and locks it in place, reporting movement as deltas only. What a + /// look-around or orbit drag runs in, so the pointer cannot escape the window mid-drag. + public static void EnableRelativeMouseMode() + { + if (_relativeMouseMode) return; + _relativeMouseMode = true; + SDL.SetWindowRelativeMouseMode(_window, true); + } + + public static void DisableRelativeMouseMode() + { + if (!_relativeMouseMode) return; + _relativeMouseMode = false; + SDL.SetWindowRelativeMouseMode(_window, false); + } + + public static bool IsRelativeMouseModeEnabled() => _relativeMouseMode; + + /// Text typed this frame, already composed (so an IME or a dead key produces the final + /// character rather than the keystrokes that built it). False when nothing was typed. + public static bool GetTypedText(out string text) + { + text = _typedText; + return text.Length > 0; + } + + public static bool IsTextInputActive() => SDL.TextInputActive(_window); + public static void EnableTextInput() => SDL.StartTextInput(_window); + public static void DisableTextInput() => SDL.StopTextInput(_window); + + public static string GetClipboardText() => SDL.GetClipboardText() ?? string.Empty; + public static void SetClipboardText(string text) => SDL.SetClipboardText(text); +} diff --git a/ReLunacy/Utility/Host/KeyboardKey.cs b/ReLunacy/Utility/Host/KeyboardKey.cs new file mode 100644 index 0000000..24c6de4 --- /dev/null +++ b/ReLunacy/Utility/Host/KeyboardKey.cs @@ -0,0 +1,52 @@ +namespace ReLunacy.Utility; + +/// A physical key, by USB HID scancode. +/// +/// Values ARE SDL3 scancodes, so can index SDL's keyboard state array +/// with one directly. Scancodes describe key POSITION, not the character it produces, which is what +/// makes WASD stay under the left hand on an AZERTY layout. +public enum KeyboardKey +{ + Unknown = 0, + + A = 4, B = 5, C = 6, D = 7, E = 8, F = 9, G = 10, H = 11, I = 12, + J = 13, K = 14, L = 15, M = 16, N = 17, O = 18, P = 19, Q = 20, R = 21, + S = 22, T = 23, U = 24, V = 25, W = 26, X = 27, Y = 28, Z = 29, + + // The number ROW. Scancode order runs 1..9 then 0, not 0..9. + Number1 = 30, Number2 = 31, Number3 = 32, Number4 = 33, Number5 = 34, + Number6 = 35, Number7 = 36, Number8 = 37, Number9 = 38, Number0 = 39, + + Enter = 40, Escape = 41, BackSpace = 42, Tab = 43, Space = 44, + Minus = 45, Equal = 46, BracketLeft = 47, BracketRight = 48, BackSlash = 49, + Semicolon = 51, Apostrophe = 52, Grave = 53, Comma = 54, Period = 55, Slash = 56, + CapsLock = 57, + + F1 = 58, F2 = 59, F3 = 60, F4 = 61, F5 = 62, F6 = 63, + F7 = 64, F8 = 65, F9 = 66, F10 = 67, F11 = 68, F12 = 69, + + PrintScreen = 70, ScrollLock = 71, Pause = 72, + Insert = 73, Home = 74, PageUp = 75, Delete = 76, End = 77, PageDown = 78, + Right = 79, Left = 80, Down = 81, Up = 82, + + NumLock = 83, + KeypadDivide = 84, KeypadMultiply = 85, KeypadMinus = 86, KeypadPlus = 87, KeypadEnter = 88, + Keypad1 = 89, Keypad2 = 90, Keypad3 = 91, Keypad4 = 92, Keypad5 = 93, + Keypad6 = 94, Keypad7 = 95, Keypad8 = 96, Keypad9 = 97, Keypad0 = 98, + KeypadDecimal = 99, + + Menu = 118, + + ControlLeft = 224, ShiftLeft = 225, AltLeft = 226, WinLeft = 227, + ControlRight = 228, ShiftRight = 229, AltRight = 230, WinRight = 231, +} + +/// Values are SDL3's 1-based button indices, which is what its button events report. +public enum MouseButton +{ + Left = 1, + Middle = 2, + Right = 3, + X1 = 4, + X2 = 5, +} diff --git a/ReLunacy/Utility/Host/MainRenderTarget.cs b/ReLunacy/Utility/Host/MainRenderTarget.cs new file mode 100644 index 0000000..c5e3995 --- /dev/null +++ b/ReLunacy/Utility/Host/MainRenderTarget.cs @@ -0,0 +1,90 @@ +using Veldrith; + +namespace ReLunacy.Utility; + +/// The offscreen surface the whole UI is drawn into, plus the single-sampled copy of it that +/// gets presented. +/// +/// Two textures because of MSAA: a multisampled image cannot be sampled by a shader, so each frame +/// resolves into and the blit reads that. With MSAA off the resolve +/// becomes a straight copy, and the pair stays because the alternative is two code paths for the sake +/// of one texture. +public sealed class MainRenderTarget : IDisposable +{ + private readonly GraphicsDevice _graphicsDevice; + private readonly PixelFormat _colorFormat; + private readonly PixelFormat _depthFormat; + + public uint Width { get; private set; } + public uint Height { get; private set; } + public TextureSampleCount SampleCount { get; } + + public Texture ColorTexture { get; private set; } = null!; + public Texture DepthTexture { get; private set; } = null!; + public Framebuffer Framebuffer { get; private set; } = null!; + + public Texture ResolveTexture { get; private set; } = null!; + public TextureView ResolveTextureView { get; private set; } = null!; + + public MainRenderTarget( + GraphicsDevice graphicsDevice, uint width, uint height, TextureSampleCount sampleCount, + PixelFormat colorFormat = PixelFormat.R8G8B8A8UNorm, + PixelFormat depthFormat = PixelFormat.D32FloatS8UInt) + { + _graphicsDevice = graphicsDevice; + _colorFormat = colorFormat; + _depthFormat = depthFormat; + SampleCount = sampleCount; + Create(width, height); + } + + private void Create(uint width, uint height) + { + Width = Math.Max(1, width); + Height = Math.Max(1, height); + var factory = _graphicsDevice.ResourceFactory; + + ColorTexture = factory.CreateTexture(new TextureDescription( + Width, Height, 1, 1, 1, _colorFormat, + TextureUsage.RenderTarget | TextureUsage.Sampled, TextureType.Texture2D, SampleCount)); + + DepthTexture = factory.CreateTexture(new TextureDescription( + Width, Height, 1, 1, 1, _depthFormat, + TextureUsage.DepthStencil, TextureType.Texture2D, SampleCount)); + + Framebuffer = factory.CreateFramebuffer(new FramebufferDescription(DepthTexture, ColorTexture)); + + // Never multisampled, whatever the target is: this is the resolve destination and the only one + // of the two a shader can read. + ResolveTexture = factory.CreateTexture(TextureDescription.Texture2D( + Width, Height, 1, 1, _colorFormat, TextureUsage.Sampled)); + ResolveTextureView = factory.CreateTextureView(ResolveTexture); + } + + public void Resize(uint width, uint height) + { + if (width == Width && height == Height) return; + DestroyResources(); + Create(width, height); + } + + /// Collapses this frame's render into , ready to be blitted. + public void Resolve(CommandList commandList) + { + if (SampleCount != TextureSampleCount.Count1) + commandList.ResolveTexture(ColorTexture, ResolveTexture); + else + commandList.CopyTexture(ColorTexture, ResolveTexture); + } + + private void DestroyResources() + { + ResolveTextureView?.Dispose(); + ResolveTexture?.Dispose(); + Framebuffer?.Dispose(); + DepthTexture?.Dispose(); + ColorTexture?.Dispose(); + } + + public void Dispose() => DestroyResources(); +} diff --git a/ReLunacy/Utility/Icons.cs b/ReLunacy/Utility/Icons.cs new file mode 100644 index 0000000..7826752 --- /dev/null +++ b/ReLunacy/Utility/Icons.cs @@ -0,0 +1,65 @@ +namespace ReLunacy.Utility; + +/// Font Awesome 6 Free Solid glyphs, merged into the default ImGui font by ImGuiController. +/// Use them anywhere ImGui takes text - a label, a button, a menu item - either directly +/// (ImGui.Text(Icons.Folder)) or combined with text via / +/// . +/// +/// Stored as \uXXXX escapes (all in the Private Use Area the loader whitelists, +/// 0xE000-0xF8FF) so the source stays ASCII. Every codepoint was verified present in +/// Assets/Fonts/fa-solid-900.ttf (Font Awesome 6.7.2). To add more: look the icon up on +/// fontawesome.com (Free + Solid), take its Unicode value, confirm it's in that range. +public static class Icons +{ + // General / app + public const string Home = "\uf015"; + public const string Gear = "\uf013"; + public const string Gears = "\uf085"; + public const string Wrench = "\uf0ad"; + public const string Save = "\uf0c7"; // floppy-disk + public const string Search = "\uf002"; // magnifying-glass + public const string Close = "\uf00d"; // xmark + public const string Check = "\uf00c"; + public const string Plus = "\uf067"; + public const string Minus = "\uf068"; + public const string Trash = "\uf1f8"; + public const string Download = "\uf019"; + public const string Upload = "\uf093"; + public const string Refresh = "\uf021"; // arrows-rotate + public const string Info = "\uf05a"; // circle-info + public const string Warning = "\uf071"; // triangle-exclamation + + // Files / assets + public const string Folder = "\uf07b"; + public const string FolderOpen = "\uf07c"; + public const string File = "\uf15b"; + + // View / visibility + public const string Eye = "\uf06e"; + public const string EyeSlash = "\uf070"; + public const string Camera = "\uf030"; + public const string Play = "\uf04b"; + public const string Pause = "\uf04c"; + + // Level assets (the editor's domain) + public const string Cube = "\uf1b2"; // mobys + public const string Cubes = "\uf1b3"; // ties + public const string Image = "\uf03e"; // textures + public const string Palette = "\uf53f"; // shaders / materials + public const string VectorSquare = "\uf5cb"; // volumes + public const string Map = "\uf279"; // level + public const string MountainSun = "\ue52f"; // terrain / ufrags + public const string Tree = "\uf1bb"; // foliage + public const string Droplet = "\uf043"; + public const string LayerGroup = "\uf5fd"; + + // Lighting + public const string Lightbulb = "\uf0eb"; + public const string Sun = "\uf185"; + + // Debug / dev + public const string Bug = "\uf188"; + public const string Code = "\uf121"; + public const string Terminal = "\uf120"; + public const string Gamepad = "\uf11b"; +} diff --git a/ReLunacy/Utility/ImGuiController.cs b/ReLunacy/Utility/ImGuiController.cs index 894cd2d..e4220f5 100644 --- a/ReLunacy/Utility/ImGuiController.cs +++ b/ReLunacy/Utility/ImGuiController.cs @@ -1,8 +1,6 @@ using System.Numerics; using System.Runtime.CompilerServices; -using Bliss.CSharp.Interact; -using Bliss.CSharp.Interact.Keyboards; -using Bliss.CSharp.Interact.Mice; +using System.Runtime.InteropServices; using Veldrith; using Veldrith.SPIRV; @@ -69,14 +67,21 @@ public ImGuiController(GraphicsDevice graphicsDevice, OutputDescription outputDe if (File.Exists(fontAwesomePath)) { var config = ImGui.ImFontConfig(); - config.MergeMode = true; + config.MergeMode = true; // fold the icons into the default font's glyph space config.PixelSnapH = true; - config.GlyphMinAdvanceX = 13f; - ushort[] ranges = [0xf000, 0xf9ff, 0]; - fixed (ushort* rangesPtr = ranges) - { - io.Fonts.AddFontFromFileTTF(fontAwesomePath, 13f, config); - } + config.GlyphMinAdvanceX = 13f; // uniform advance so icons align in a column + + // Font Awesome 6 icons live in the Private Use Area (0xE000-0xF8FF in this build). + // TWO reasons the old attempt silently failed: this ImGui is compiled with 32-bit + // ImWchar so ranges are uint (not ushort), AND the pinned array was never actually + // passed to AddFontFromFileTTF. The ranges pointer must OUTLIVE this call - ImGui + // keeps it and reads it lazily at atlas-build time - so it's allocated unmanaged and + // intentionally never freed (a one-time 12-byte leak, not per-frame). See Utility.Icons. + uint* iconRanges = (uint*)NativeMemory.Alloc((nuint)(3 * sizeof(uint))); + iconRanges[0] = 0xE000u; + iconRanges[1] = 0xF8FFu; + iconRanges[2] = 0u; + io.Fonts.AddFontFromFileTTF(fontAwesomePath, 13f, config, iconRanges); config.Destroy(); } } @@ -133,7 +138,9 @@ private void CreateDeviceResources(GraphicsDevice gd, OutputDescription outputDe var pipelineDescription = new GraphicsPipelineDescription( BlendStateDescription.SINGLE_ALPHA_BLEND, new DepthStencilStateDescription(false, false, ComparisonKind.Always), - new RasterizerStateDescription(FaceCullMode.None, PolygonFillMode.Solid, FrontFace.Clockwise, true, true), + new RasterizerStateDescription(FaceCullMode.None, PolygonFillMode.Solid, FrontFace.Clockwise, + depthClipEnabled: true, depthBias: 0, slopeScaledDepthBias: 0f, depthBiasClamp: 0f, + scissorTestEnabled: true), PrimitiveTopology.TriangleList, shaderSet, [_layout, _textureLayout], @@ -145,10 +152,10 @@ private void CreateDeviceResources(GraphicsDevice gd, OutputDescription outputDe // Point-sampled for ALL ImGui drawing, deliberately: texture-inspection previews // (TexturesExplorer etc.) must show raw texels, and the 3D viewport image is blitted 1:1 // (its render texture is sized to the viewport), so filtering it would be a no-op anyway. - // Scene texture filtering lives entirely on the 3D side — see + // Scene texture filtering lives entirely on the 3D side - see // AssetManager.SetTextureFiltering. A previous attempt to make this per-binding (rebinding // resource set 0 inside the per-command loop below) was suspected during a GPUVM-fault - // investigation and reverted, but never confirmed as the cause — the fault was in fact the + // investigation and reverted, but never confirmed as the cause - the fault was in fact the // lit effect's descriptor set numbering, see AssetManager.BuildLitModelEffect. Restoring // the per-binding sampler here is probably safe; it just hasn't been retried since. _mainResourceSet = factory.CreateResourceSet(new ResourceSetDescription(_layout, _projMatrixBuffer, gd.PointSampler)); diff --git a/ReLunacy/Utility/ImGuiPlus.cs b/ReLunacy/Utility/ImGuiPlus.cs index ea567b0..d31cc09 100644 --- a/ReLunacy/Utility/ImGuiPlus.cs +++ b/ReLunacy/Utility/ImGuiPlus.cs @@ -84,6 +84,38 @@ public static void CenteredImage(ImTextureRef textureId, Vector2 size, float piv ImGui.Image(textureId, size); } + /// A clickable, underlined text link that opens in the browser + /// on click and shows a hand cursor + URL tooltip on hover. Behaves as a single inline item, so + /// SameLine works around it. + public static void Hyperlink(string label, string url) + { + var color = new Vector4(0.35f, 0.65f, 1f, 1f); + ImGui.TextColored(color, label); + + var min = ImGui.GetItemRectMin(); + var max = ImGui.GetItemRectMax(); + ImGui.GetWindowDrawList().AddLine(new Vector2(min.X, max.Y - 1f), new Vector2(max.X, max.Y - 1f), ImGui.GetColorU32(color)); + + if (ImGui.IsItemHovered()) + { + ImGui.SetMouseCursor(ImGuiMouseCursor.Hand); + ImGui.SetTooltip(url); + if (ImGui.IsMouseClicked(ImGuiMouseButton.Left)) + ShellUtils.OpenUrl(url); + } + } + + /// Combines a Font Awesome glyph (see ) and text into one label with + /// a small gap, for use as a button/menu-item/header label - e.g. + /// ImGui.MenuItem(ImGuiPlus.Label(Icons.FolderOpen, "Open level")). The icon is merged + /// into the default font, so it just renders inline with the text. + public static string Label(string icon, string text) => $"{icon} {text}"; + + /// An icon-only button. keeps ImGui's label-based identity unique + /// when several buttons share the same glyph - pass something stable and distinct per button. + public static bool IconButton(string icon, string id, Vector2? size = null) => + size is { } s ? ImGui.Button($"{icon}##{id}", s) : ImGui.Button($"{icon}##{id}"); + public static void CenteredText(string label, float pivot = 0.5f) { float horizontalSize = ImGui.CalcTextSize(label).X; diff --git a/ReLunacy/Utility/Localization/LanguageManager.cs b/ReLunacy/Utility/Localization/LanguageManager.cs index 53f8cd3..f4788bd 100644 --- a/ReLunacy/Utility/Localization/LanguageManager.cs +++ b/ReLunacy/Utility/Localization/LanguageManager.cs @@ -108,11 +108,11 @@ public static string Get(string key, params object[] args) return key; } - // A locale string's placeholder count can drift out of sync with its call site — most often a + // A locale string's placeholder count can drift out of sync with its call site - most often a // stale on-disk translation left over after a key's format changed elsewhere: self-healing // (above) only fills in keys that are entirely MISSING, it never reconciles an EXISTING key's // value against a call site that now passes a different number of args. string.Format throwing - // on that mismatch used to take the whole app down over a single mistranslated/stale label — + // on that mismatch used to take the whole app down over a single mistranslated/stale label - // degrade to the raw unformatted string instead. private static string SafeFormat(string fmt, object[] args) { diff --git a/ReLunacy/Utility/MarkdownRenderer.cs b/ReLunacy/Utility/MarkdownRenderer.cs new file mode 100644 index 0000000..0e0e823 --- /dev/null +++ b/ReLunacy/Utility/MarkdownRenderer.cs @@ -0,0 +1,287 @@ +using System.Numerics; +using System.Text.RegularExpressions; + +namespace ReLunacy.Utility; + +/// +/// Minimal, self-contained Markdown renderer for Dear ImGui, written for the update changelog +/// (GitHub release bodies - see ). It deliberately supports only a small +/// subset: headers, bold, italic, underline, inline code, links, and bullet / numbered lists. +/// +/// Only the single default ImGui font is loaded (see ImGuiController - there is no bold or italic +/// font family), so styling is faked: bold is over-drawn a fraction of a pixel to fatten the +/// glyphs, and italic - which needs a real slanted font to look right - is shown as a dimmed +/// emphasis colour rather than a true slant. Headers use ImGui 1.92's dynamic font sizing +/// (PushFont(font, size)) to scale the one font up. This is not a CommonMark parser and is +/// intentionally not extensible; it only has to make a release's notes readable in-app. +/// +public static class MarkdownRenderer +{ + private static readonly Vector4 LinkColor = new(0.35f, 0.65f, 1f, 1f); + private static readonly Vector4 CodeColor = new(0.90f, 0.72f, 0.52f, 1f); + private static readonly Vector4 ItalicColor = new(0.78f, 0.78f, 0.78f, 1f); + private static readonly Vector4 HeaderColor = new(1f, 1f, 1f, 1f); + + // Header sizes are multipliers of the base font size, so they track the user's font scale + // instead of being hard pixel sizes. Index 0 = '#', 1 = '##', 2 = '###' and deeper. + private static readonly float[] HeaderScales = [1.6f, 1.4f, 1.2f]; + + private static readonly Regex HeaderPattern = new(@"^(#{1,6})\s+(.*)$", RegexOptions.Compiled); + private static readonly Regex BulletPattern = new(@"^(\s*)[-*+]\s+(.*)$", RegexOptions.Compiled); + private static readonly Regex NumberedPattern = new(@"^(\s*)(\d+)[.)]\s+(.*)$", RegexOptions.Compiled); + + private readonly struct Run(string text, bool bold, bool italic, bool underline, bool code, string? link) + { + public readonly string Text = text; + public readonly bool Bold = bold; + public readonly bool Italic = italic; + public readonly bool Underline = underline; + public readonly bool Code = code; + public readonly string? Link = link; + } + + /// Renders the whole markdown document at the current cursor, wrapping to the content + /// region's width. Call inside a scrolling child if the text can be long. + public static void Render(string? markdown) + { + if (string.IsNullOrEmpty(markdown)) return; + + float wrapWidth = ImGui.GetContentRegionAvail().X; + var lines = markdown.Replace("\r\n", "\n").Replace('\r', '\n').Split('\n'); + foreach (var line in lines) + RenderLine(line, wrapWidth); + } + + private static void RenderLine(string line, float wrapWidth) + { + // Blank line -> vertical gap between paragraphs. + if (string.IsNullOrWhiteSpace(line)) + { + ImGui.Spacing(); + return; + } + + string trimmed = line.Trim(); + if (trimmed is "---" or "***" or "___") + { + ImGui.Separator(); + return; + } + + var header = HeaderPattern.Match(line); + if (header.Success) + { + int level = header.Groups[1].Value.Length; + float scale = HeaderScales[Math.Min(level, HeaderScales.Length) - 1]; + ImGui.Spacing(); + ImGui.PushFont(ImGui.GetFont(), ImGui.GetFontSize() * scale); + RenderRuns(ParseInline(header.Groups[2].Value), wrapWidth, HeaderColor); + ImGui.PopFont(); + // A rule under the top-level headers mirrors how GitHub renders h1/h2. + if (level <= 2) ImGui.Separator(); + return; + } + + var bullet = BulletPattern.Match(line); + if (bullet.Success) + { + RenderListItem("- ", IndentFor(bullet.Groups[1].Value), ParseInline(bullet.Groups[2].Value), wrapWidth); + return; + } + + var numbered = NumberedPattern.Match(line); + if (numbered.Success) + { + RenderListItem($"{numbered.Groups[2].Value}. ", IndentFor(numbered.Groups[1].Value), ParseInline(numbered.Groups[3].Value), wrapWidth); + return; + } + + RenderRuns(ParseInline(line), wrapWidth, null); + } + + // Two leading spaces (or a tab) per nesting level, kept modest so deep lists don't run off. + private static float IndentFor(string leadingWhitespace) + { + int spaces = leadingWhitespace.Replace("\t", " ").Length; + return spaces / 2 * ImGui.GetFontSize(); + } + + private static void RenderListItem(string marker, float indent, List runs, float wrapWidth) + { + float baseX = ImGui.GetCursorPosX(); + float markerX = baseX + indent; + ImGui.SetCursorPosX(markerX); + ImGui.TextUnformatted(marker); + float markerWidth = ImGui.GetItemRectMax().X - ImGui.GetItemRectMin().X; + float textX = markerX + markerWidth; + + // Keep the item's text on the marker's line, hanging-indented under textX so wrapped lines + // align with the first word rather than the bullet. + ImGui.SameLine(0, 0); + RenderRuns(runs, wrapWidth - (textX - baseX), null, textX); + } + + /// Lays out a line's inline runs word-by-word, wrapping within + /// [lineStartX, lineStartX + wrapWidth]. Spacing between words is derived from the source text, + /// so adjacent runs with no space between them (e.g. **bold**text) stay glued. + private static void RenderRuns(List runs, float wrapWidth, Vector4? forcedColor, float? lineStartXOverride = null) + { + float lineStartX = lineStartXOverride ?? ImGui.GetCursorPosX(); + float lineRight = lineStartX + wrapWidth; + float wordSpace = ImGui.CalcTextSize(" ").X; + + ImGui.SetCursorPosX(lineStartX); + float penX = lineStartX; + bool atLineStart = true; + bool pendingSpace = false; + + foreach (var run in runs) + { + int i = 0; + int len = run.Text.Length; + while (i < len) + { + if (char.IsWhiteSpace(run.Text[i])) + { + pendingSpace = true; + i++; + continue; + } + + int start = i; + while (i < len && !char.IsWhiteSpace(run.Text[i])) i++; + string word = run.Text[start..i]; + + float wordW = ImGui.CalcTextSize(word).X; + float spaceW = !atLineStart && pendingSpace ? wordSpace : 0f; + bool fits = atLineStart || penX + spaceW + wordW <= lineRight; + + if (fits && !atLineStart) + { + ImGui.SameLine(0, spaceW); + penX += spaceW; + } + else + { + // New line: either the first word, or a wrap. The previous item already + // advanced the cursor down a line, so only X needs resetting. + ImGui.SetCursorPosX(lineStartX); + penX = lineStartX; + } + + EmitWord(word, run, forcedColor); + penX += wordW; + atLineStart = false; + pendingSpace = false; + } + } + } + + private static void EmitWord(string word, in Run run, Vector4? forcedColor) + { + Vector4? color = run.Link != null ? LinkColor + : run.Code ? CodeColor + : forcedColor ?? (run.Italic ? ItalicColor : null); + + if (color.HasValue) ImGui.PushStyleColor(ImGuiCol.Text, color.Value); + ImGui.TextUnformatted(word); + if (color.HasValue) ImGui.PopStyleColor(); + + uint col32 = color.HasValue ? ImGui.GetColorU32(color.Value) : ImGui.GetColorU32(ImGuiCol.Text); + Vector2 min = ImGui.GetItemRectMin(); + Vector2 max = ImGui.GetItemRectMax(); + var draw = ImGui.GetWindowDrawList(); + + // Faux bold: over-draw the same glyphs nudged sideways to thicken the strokes. The short + // AddText overload uses the current font/size, so this matches header scaling too. + if (run.Bold) + draw.AddText(new Vector2(min.X + 0.7f, min.Y), col32, word); + + if (run.Underline || run.Link != null) + { + float y = max.Y - 1f; + draw.AddLine(new Vector2(min.X, y), new Vector2(max.X, y), col32); + } + + if (run.Link != null && ImGui.IsItemHovered()) + { + ImGui.SetMouseCursor(ImGuiMouseCursor.Hand); + ImGui.SetTooltip(run.Link); + if (ImGui.IsMouseClicked(ImGuiMouseButton.Left)) + ShellUtils.OpenUrl(run.Link); + } + } + + /// Splits one line into styled runs. Emphasis nesting is tracked as toggle flags; + /// links and inline code are read verbatim (no emphasis parsed inside them). Underscore-pair + /// __x__ and the explicit <u>x</u> tag both map to underline (Markdown + /// has no native underline); **x** is bold and single *x* / _x_ italic. + /// + private static List ParseInline(string text) + { + var runs = new List(); + var sb = new StringBuilder(); + bool bold = false, italic = false, underline = false; + + void Flush() + { + if (sb.Length == 0) return; + runs.Add(new Run(sb.ToString(), bold, italic, underline, false, null)); + sb.Clear(); + } + + int i = 0; + int n = text.Length; + while (i < n) + { + char c = text[i]; + + // [label](url) + if (c == '[') + { + int close = text.IndexOf(']', i + 1); + if (close > 0 && close + 1 < n && text[close + 1] == '(') + { + int urlEnd = text.IndexOf(')', close + 2); + if (urlEnd > 0) + { + Flush(); + string label = text[(i + 1)..close]; + string url = text[(close + 2)..urlEnd]; + runs.Add(new Run(label, bold, italic, underline, false, url)); + i = urlEnd + 1; + continue; + } + } + } + + // `code` + if (c == '`') + { + int close = text.IndexOf('`', i + 1); + if (close > 0) + { + Flush(); + runs.Add(new Run(text[(i + 1)..close], bold, italic, underline, true, null)); + i = close + 1; + continue; + } + } + + if (MatchesAt(text, i, "")) { Flush(); underline = true; i += 3; continue; } + if (MatchesAt(text, i, "")) { Flush(); underline = false; i += 4; continue; } + if (c == '*' && i + 1 < n && text[i + 1] == '*') { Flush(); bold = !bold; i += 2; continue; } + if (c == '_' && i + 1 < n && text[i + 1] == '_') { Flush(); underline = !underline; i += 2; continue; } + if (c is '*' or '_') { Flush(); italic = !italic; i++; continue; } + + sb.Append(c); + i++; + } + + Flush(); + return runs; + } + + private static bool MatchesAt(string s, int i, string token) => + i + token.Length <= s.Length && string.CompareOrdinal(s, i, token, 0, token.Length) == 0; +} diff --git a/ReLunacy/Utility/MouseGrabHandler.cs b/ReLunacy/Utility/MouseGrabHandler.cs index 6e28fd5..94e5d18 100644 --- a/ReLunacy/Utility/MouseGrabHandler.cs +++ b/ReLunacy/Utility/MouseGrabHandler.cs @@ -1,6 +1,4 @@ using System.Numerics; -using Bliss.CSharp.Interact; -using Bliss.CSharp.Interact.Mice; namespace ReLunacy.Utility; diff --git a/ReLunacy/Utility/ResourceManager.cs b/ReLunacy/Utility/ResourceManager.cs index 93686c4..5d5e33b 100644 --- a/ReLunacy/Utility/ResourceManager.cs +++ b/ReLunacy/Utility/ResourceManager.cs @@ -1,5 +1,5 @@ using System.Reflection; -using Bliss.CSharp.Images; +using ReLunacy.Engine.Rendering.Resources; namespace ReLunacy.Utility; @@ -21,7 +21,7 @@ public static ResourcesManager LoadResourcesFromManifest() byte[] resBuffer = new byte[resStream.Length]; resStream.ReadExactly(resBuffer, 0, (int)resStream.Length); - var displayResName = resName.Split(".")[2..].Stringify("."); + var displayResName = string.Join(".", resName.Split(".")[2..]); resMan.Buffers.TryAdd(displayResName, resBuffer); } return resMan; diff --git a/ReLunacy/Utility/ShellUtils.cs b/ReLunacy/Utility/ShellUtils.cs index 40fbe11..58d65d2 100644 --- a/ReLunacy/Utility/ShellUtils.cs +++ b/ReLunacy/Utility/ShellUtils.cs @@ -21,4 +21,19 @@ public static void OpenFolder(string directoryPath) psi.ArgumentList.Add(directoryPath); Process.Start(psi); } + + /// Opens a URL in the user's default browser. UseShellExecute lets the OS resolve the + /// default handler; failures (e.g. no browser, sandboxed environment) are logged rather than + /// thrown so a bad link in rendered changelog text can't crash the UI. + public static void OpenUrl(string url) + { + try + { + Process.Start(new ProcessStartInfo(url) { UseShellExecute = true }); + } + catch (Exception e) + { + LunaLog.LogWarn($"Failed to open URL '{url}': {e.Message}"); + } + } } diff --git a/ReLunacy/Utility/TextureUtils.cs b/ReLunacy/Utility/TextureUtils.cs deleted file mode 100644 index 00102b1..0000000 --- a/ReLunacy/Utility/TextureUtils.cs +++ /dev/null @@ -1,146 +0,0 @@ -using Bliss.CSharp.Colors; -using Bliss.CSharp.Images; -using System.Numerics; - -namespace ReLunacy.Utility; - -public record struct RGBA8888 -{ - public RGBA8888(Vector4 vec4) - { - Red = (byte)(vec4.X * 255); - Green = (byte)(vec4.Y * 255); - Blue = (byte)(vec4.Z * 255); - Alpha = (byte)(vec4.W * 255); - } - - public RGBA8888(Color color) - { - Red = color.R; - Green = color.G; - Blue = color.B; - Alpha = color.A; - } - - public RGBA8888(byte red = 0xFF, byte green = 0xFF, byte blue = 0xFF, byte alpha = 0xFF) - { - Red = red; - Green = green; - Blue = blue; - Alpha = alpha; - } - - public byte Red; - public byte Green; - public byte Blue; - public byte Alpha; - - public readonly Color ToBlissColor() => new(Red, Green, Blue, Alpha); - public readonly Vector4 ToNormalizedVector() => new(Red / (float)0xFF, Green / (float)0xFF, Blue / (float)0xFF, Alpha / (float)0xFF); -} - -public static class TextureUtils -{ - public static byte[] ARGB8888ToRGBA8888(in byte[] rawData, int width, int height) - { - const int PIXEL_SIZE = 4; - var size = width * height * PIXEL_SIZE; - byte[] result = new byte[size]; - - if (rawData.Length / PIXEL_SIZE != width * height) - throw new InvalidOperationException($"Pixel count does not match the raw data size ! ({rawData.Length / PIXEL_SIZE} pixels, but {width * height} pixels expected)"); - - for(int i = 0; i < size; i += PIXEL_SIZE) - { - result[i + 0] = rawData[i + 1]; - result[i + 1] = rawData[i + 2]; - result[i + 2] = rawData[i + 3]; - result[i + 3] = rawData[i + 0]; - // yeah it's just swapping lol - } - - return result; - } - - public enum Colours - { - Red = 0, - Green = 1, - Blue = 2, - Alpha = 3, - } - - public static byte[] ColourAsMain(in byte[] rawData, Colours colourFilter) - { - const int PIXEL_SIZE = 4; - - if (rawData.Length % PIXEL_SIZE != 0) - throw new InvalidOperationException($"This image does not have the right count of bytes !"); - - byte[] result = new byte[rawData.Length]; - - for (int i = 0; i < rawData.Length; i += PIXEL_SIZE) - { - result[i + 0] = rawData[i + (int)colourFilter]; - result[i + 1] = rawData[i + (int)colourFilter]; - result[i + 2] = rawData[i + (int)colourFilter]; - result[i + 3] = 0xFF; - } - - return result; - } - - public static Image ColourAsMain(this Image img, Colours colourFilter) - { - for(int y = 0; y < img.Height; y++) - for(int x = 0; x < img.Width; x++) - { - Color currCol = img.GetColor(x, y); - byte pxlCol; - switch(colourFilter) - { - case Colours.Red: - pxlCol = currCol.R; - break; - case Colours.Green: - pxlCol = currCol.G; - break; - case Colours.Blue: - pxlCol = currCol.B; - break; - case Colours.Alpha: - pxlCol = currCol.A; - break; - default: - pxlCol = 0; - break; - } - - img.SetPixel(x, y, new(pxlCol, pxlCol, pxlCol, 0xFF)); - } - - return img; - } - - public static byte[] RGB565ToRGBA8888(in byte[] rawData, int width, int height) - { - const int RGB565_PS = 2; - const int RGBA8888_PS = 4; - var pixelCount = width * height; - byte[] result = new byte[width * height * RGBA8888_PS]; - - if (rawData.Length / RGB565_PS != pixelCount) - throw new InvalidOperationException($"Pixel count does not match the raw data size ! ({rawData.Length / RGB565_PS} pixels, but {width * height} expected)"); - - for(int i = 0; i < pixelCount; i++) - { - result[i * RGBA8888_PS + 0] = (byte)((rawData[i * RGB565_PS + 0] & 0b11111000) >> 3); - result[i * RGBA8888_PS + 1] = (byte)((byte)((rawData[i * RGB565_PS + 0] & 0b00000111) << 3) | (byte)(rawData[i * RGB565_PS + 1] & 0b11100000)); - result[i * RGBA8888_PS + 2] = (byte)(rawData[i * RGB565_PS + 1] & 0b00011111); - result[i * RGBA8888_PS + 3] = 0xFF; - } - - return result; - } - -} diff --git a/ReLunacy/Utility/UpdateChecker.cs b/ReLunacy/Utility/UpdateChecker.cs index cdeafd4..153f649 100644 --- a/ReLunacy/Utility/UpdateChecker.cs +++ b/ReLunacy/Utility/UpdateChecker.cs @@ -6,15 +6,20 @@ namespace ReLunacy.Utility; +/// One commit in the range between the running build and the available update, as listed +/// by GitHub's compare API. is only the summary line; +/// links to the full commit at . +public readonly record struct CommitInfo(string ShortSha, string Message, string Url); + // Recreated after the LibLunacy/Bliss merge deleted the old implementation (see git history for -// the pre-rewrite version this is loosely based on) — now channel-aware per EditorSettings. +// the pre-rewrite version this is loosely based on) - now channel-aware per EditorSettings. // // Stable checks GitHub's normal "latest release" and compares its tag as a Version against // ProgramInfo.Version, same as before. // // Nightly is a different shape entirely: .github/workflows/nightly.yml keeps a single rolling // release under the "nightly" tag, and (per its allowUpdates/replacesArtifacts settings) -// accumulates every nightly build's artifacts there rather than replacing them — so a nightly tag +// accumulates every nightly build's artifacts there rather than replacing them - so a nightly tag // has no single meaningful version number, just a growing list of dated, commit-stamped assets. // Comparison instead extracts the commit hash baked into the newest asset's filename for this // platform and compares it against NightlyBuildInfo.CommitHash (this build's own identity, which @@ -56,6 +61,7 @@ private static async Task CheckStable() string? tag = (string?)data["tag_name"]; string? url = (string?)data["html_url"]; string? publishedAt = (string?)data["published_at"]; + string? body = (string?)data["body"]; if (tag == null || url == null || publishedAt == null) return; if (!TryParseVersion(tag, out var newVersion) || !TryParseVersion(ProgramInfo.Version, out var currentVersion)) @@ -67,7 +73,11 @@ private static async Task CheckStable() if (newVersion > currentVersion) { LunaLog.LogInfo($"A stable update is available: v{tag}"); - LunaWindow.Instance.AddFrame(new UpdateInfoFrame(url, tag, DateTime.Parse(publishedAt, CultureInfo.InvariantCulture))); + // Commits between the running release's tag and the new one. If the current tag can't + // be found on the remote (never happens for a real published build), this comes back + // empty and the frame just omits the section. + var commits = await FetchCommitsAsync(client, ProgramInfo.Version, tag); + LunaWindow.Instance.AddFrame(new UpdateInfoFrame(url, tag, DateTime.Parse(publishedAt, CultureInfo.InvariantCulture), changelog: body, commits: commits)); } else { @@ -88,11 +98,12 @@ private static async Task CheckNightly() var data = JObject.Parse(await response.Content.ReadAsStringAsync()); string? url = (string?)data["html_url"]; + string? body = (string?)data["body"]; var assets = data["assets"] as JArray; if (url == null || assets == null || assets.Count == 0) return; // Filenames are "ReLunacy-nightly-{yyyy-MM-dd}.{shortCommit}.{platformRid}.{ext}" (see the - // nightly workflow) — pick this platform's newest by filename, which sorts lexicographically + // nightly workflow) - pick this platform's newest by filename, which sorts lexicographically // the same as chronologically thanks to the leading yyyy-MM-dd. string platformRid = OperatingSystem.IsWindows() ? "win-x64" : "linux-x64"; var latestForPlatform = assets @@ -117,10 +128,58 @@ private static async Task CheckNightly() } LunaLog.LogInfo($"A nightly update is available: {assetName}"); + // Nightly builds have no changelog body, so the commit list IS the "what's new". Compare + // from this build's own commit when it knows it (a real nightly binary - stamped by the + // workflow), otherwise from the latest stable tag so a stable user checking the nightly + // channel still gets a meaningful range. + string baseRef = NightlyBuildInfo.CommitHash ?? ProgramInfo.Version; + var commits = await FetchCommitsAsync(client, baseRef, remoteCommit); LunaWindow.Instance.AddFrame(new UpdateInfoFrame( url, assetName, publishedAt != null ? DateTime.Parse(publishedAt, CultureInfo.InvariantCulture) : DateTime.Now, - isNightly: true)); + isNightly: true, changelog: body, commits: commits)); + } + + /// Lists the commits in (baseRef, headRef] via GitHub's compare API. baseRef/headRef + /// may be tags or commit SHAs. Returns newest-first; any failure (unknown ref, offline, rate + /// limit) is logged and yields an empty list so the update frame simply hides the section + /// rather than failing the whole update check. + private static async Task> FetchCommitsAsync(HttpClient client, string baseRef, string headRef) + { + var result = new List(); + if (string.IsNullOrEmpty(baseRef) || string.IsNullOrEmpty(headRef)) return result; + + try + { + var response = await client.GetAsync($"{RepoApiBase}/compare/{baseRef}...{headRef}"); + if (!response.IsSuccessStatusCode) + { + LunaLog.LogWarn($"Could not list commits {baseRef}...{headRef}: {(int)response.StatusCode} {response.ReasonPhrase}"); + return result; + } + + var data = JObject.Parse(await response.Content.ReadAsStringAsync()); + if (data["commits"] is not JArray commits) return result; + + foreach (var commit in commits) + { + string sha = (string?)commit["sha"] ?? ""; + string message = (string?)commit["commit"]?["message"] ?? ""; + string commitUrl = (string?)commit["html_url"] ?? ""; + // Commit messages are "summary\n\nbody"; only the summary line is wanted here. + string summary = message.Split('\n', 2)[0].Trim(); + result.Add(new CommitInfo(sha.Length >= 7 ? sha[..7] : sha, summary, commitUrl)); + } + + // GitHub returns the range oldest-first; show the newest commit at the top. + result.Reverse(); + } + catch (Exception e) + { + LunaLog.LogWarn($"Failed to list commits {baseRef}...{headRef}: {e.Message}"); + } + + return result; } private static string? ExtractCommitHash(string assetName) diff --git a/ReLunacy/Utility/Viewport3D.cs b/ReLunacy/Utility/Viewport3D.cs new file mode 100644 index 0000000..c3da8d1 --- /dev/null +++ b/ReLunacy/Utility/Viewport3D.cs @@ -0,0 +1,177 @@ +using System.Numerics; +using Hexa.NET.ImGui; +using ReLunacy.Engine.Rendering; +using ReLunacy.Engine.Scene; + +namespace ReLunacy.Utility; + +/// Hosts a 3D viewport inside an ImGui window: the rendered image, the toolbar over it, the +/// gizmo, and the arbitration that decides which of those gets the mouse. +/// +/// Every frame that shows a 3D view used to carry its own copy of this plumbing (mouse position +/// relative to the image, a hover test, a "a click happened" latch consumed somewhere further down, +/// and the relative-mouse-mode flag), which meant the rules for who wins a click existed once per +/// frame and had already drifted between them. They live here now, in one order, stated once: +/// +/// overlay UI > gizmo > picking > camera +/// +/// The order is expressed by the order the calls are made in, so it is visible at the call site +/// rather than encoded as a chain of boolean guards: +/// +/// +/// _viewport.Begin("view3d"); +/// // ... camera control, gated on AllowCameraInput, reporting drags via SetMouseCaptured +/// _viewport.DrawImage(binding); // image, then the overlay opens over it +/// _viewport.Overlay.ToggleButton(...); // claims the click if it was hovered +/// _viewport.Gizmo(gizmoController, camera, selected); // claims it next +/// if (_viewport.TryConsumeClick()) Pick(); // only what nothing above took +/// _viewport.End(); +/// +/// +/// Nothing here renders the scene or moves a camera: those differ per viewport (the level view flies, +/// the asset preview orbits) and stay with the frame that owns them. +public sealed class Viewport3D +{ + /// The toolbar drawn over the image. Opened by / + /// and closed by , so callers only add buttons to it. + public ViewportOverlay Overlay { get; } = new(); + + /// Top-left of the image in SCREEN space. + public Vector2 ScreenPos { get; private set; } + + /// Size of the image in whole pixels, from the content region available at . + public Vector2 Size { get; private set; } + + public int PixelWidth { get; private set; } + public int PixelHeight { get; private set; } + + /// True when the region has a drawable size. Everything downstream is a no-op otherwise. + public bool HasArea => PixelWidth > 0 && PixelHeight > 0; + + /// Cursor position relative to the image's top-left corner, which is the space the + /// renderer's Pick() and any screen-space overlay maths work in. + public Vector2 MousePos { get; private set; } + + /// Cursor is over this image, and this window is the one ImGui considers hovered (so a + /// panel drawn on top of the viewport blocks it). + public bool IsHovered { get; private set; } + + /// Whether the frame's own camera controls should react to the mouse this frame. + public bool AllowCameraInput => IsHovered; + + private string _id = string.Empty; + private bool _clickPending; + private bool _claimed; + private GizmoController? _gizmo; + + // Relative mouse mode is a single global flag but there are several viewports, so ownership is + // tracked rather than assumed: a viewport only clears the flag if it is the one that set it. + // Without this, any viewport ticking while another was mid-drag would cancel that drag, which is + // exactly what used to happen between the level view and the asset preview. + private static Viewport3D? _captureOwner; + + /// Measures the region the image will occupy and samples the mouse against it. Call at the + /// point in the layout where the image goes, before anything else in the viewport. + public void Begin(string id) + { + _id = id; + ScreenPos = ImGui.GetCursorScreenPos(); + var avail = ImGui.GetContentRegionAvail(); + PixelWidth = (int)avail.X; + PixelHeight = (int)avail.Y; + // Truncated, not the raw float: the render target is an integer number of pixels, so drawing + // the image at a fractional size would resample it and blur a view that should be 1:1. + Size = new Vector2(PixelWidth, PixelHeight); + + MousePos = Input.GetMousePosition() - ScreenPos; + IsHovered = HasArea + && ImGui.IsWindowHovered() + && MousePos.X >= 0f && MousePos.Y >= 0f + && MousePos.X < Size.X && MousePos.Y < Size.Y; + + _claimed = false; + _gizmo = null; + // Latched here and resolved at TryConsumeClick, because who is entitled to the click is not + // known yet: the overlay and the gizmo only find out whether they were hit when they draw, + // which is further down the same frame. + _clickPending = IsHovered && Input.IsMouseButtonPressed(MouseButton.Left); + } + + /// Marks the mouse as spoken for this frame, so no click falls through to picking. Camera + /// drags do this via ; call it directly for anything else that + /// swallows input. + public void ClaimInput() => _claimed = true; + + /// Enters or leaves relative mouse mode on this viewport's behalf, and claims the mouse + /// while captured. Safe to call every frame with the current drag state: only edges do anything. + public void SetMouseCaptured(bool captured) + { + if (captured) _claimed = true; + if (captured == (_captureOwner == this)) return; + + ImGuiIOPtr io = ImGui.GetIO(); + if (captured) + { + // Another viewport is mid-drag. Leave its flag alone; this one's MouseGrabHandler has the + // button anyway, so the drag still tracks, it just does not also hide the cursor. + if (_captureOwner != null) return; + _captureOwner = this; + io.ConfigFlags |= ImGuiConfigFlags.NoMouse; + } + else + { + _captureOwner = null; + io.ConfigFlags &= ~ImGuiConfigFlags.NoMouse; + } + } + + /// Draws the rendered scene and opens the overlay over it. + public void DrawImage(ImTextureRef binding) + { + if (!HasArea) return; + ImGui.SetCursorScreenPos(ScreenPos); + ImGui.Image(binding, Size, Vector2.Zero, Vector2.One); + Overlay.Begin(_id, ScreenPos, Size); + } + + /// Same as with nothing to show: reserves the region so the layout + /// is identical, and still opens the overlay so its controls do not blink out whenever there is no + /// scene (which is when some of them are most useful). + public void DrawEmpty() + { + if (!HasArea) return; + ImGui.SetCursorScreenPos(ScreenPos); + ImGui.Dummy(Size); + Overlay.Begin(_id, ScreenPos, Size); + } + + /// Runs the transform gizmo over this viewport. Only after this has run does the gizmo know + /// whether it was hit, which is why must come after it. + public void Gizmo(GizmoController gizmo, EditorCamera camera, Entity? entity) + { + if (!HasArea) return; + // Close the overlay first: it holds pushed style colours and an id scope, and the gizmo is not + // part of it. + Overlay.End(); + _gizmo = gizmo; + gizmo.Render(camera, entity, ScreenPos, Size); + } + + /// True on the frame a left click landed on the image and nothing above picking wanted it. + /// Consumes the click, so it answers true at most once per frame. + public bool TryConsumeClick() + { + if (!_clickPending) return false; + _clickPending = false; + + if (_claimed || Overlay.WantsMouse) return false; + // IsOver alongside IsUsing because IsUsingAny() lags a frame behind the initial click-down (it + // wants a drag delta first), so the very first click on a handle would otherwise leak through. + if (_gizmo != null && (_gizmo.IsUsing || _gizmo.IsOver)) return false; + return true; + } + + /// Closes the overlay and restores the caller's layout cursor. No-op if the overlay was + /// already closed by . + public void End() => Overlay.End(); +} diff --git a/ReLunacy/Utility/ViewportOverlay.cs b/ReLunacy/Utility/ViewportOverlay.cs new file mode 100644 index 0000000..4ac2288 --- /dev/null +++ b/ReLunacy/Utility/ViewportOverlay.cs @@ -0,0 +1,182 @@ +using System.Numerics; +using Hexa.NET.ImGui; + +namespace ReLunacy.Utility; + +/// A compact toolbar drawn ON TOP of a viewport image, plus optional drop-down panels hanging +/// off its buttons. +/// +/// Any frame that renders a 3D viewport can use this: call straight after the +/// ImGui.Image, add buttons, open panels for the ones that are toggled on, then . +/// It positions everything in screen space over the image and restores the caller's layout cursor +/// afterwards, so the surrounding frame layout is untouched. +/// +/// Buttons drawn after the image land on top of it: same ImGui window, later draw order. +/// +/// +/// _overlay.Begin("viewport", imagePos, imageSize); +/// _overlay.ToggleButton("C", ref showClip, "Clip distance"); +/// if (showClip && _overlay.BeginPanel("clip", new Vector2(260f, 0f))) +/// { +/// ImGui.SliderFloat("Far", ref far, 1f, 10000f); +/// _overlay.EndPanel(); +/// } +/// _overlay.End(); +/// +public sealed class ViewportOverlay +{ + private const float Margin = 8f; + private const float Spacing = 4f; + private const float ButtonSize = 24f; + + private Vector2 _origin; + private Vector2 _size; + private Vector2 _cursor; + private Vector2 _restoreCursor; + private float _panelTop; + private int _panelColumn; + private bool _active; + + /// True while a viewport is small enough that the toolbar would cover most of it, in + /// which case everything below is skipped. Callers do not need to check this - the add methods + /// are all no-ops when inactive. + public bool IsActive => _active; + + /// True when the cursor is over one of this overlay's controls, so a click on it belongs to + /// the overlay and must not also reach the gizmo or the picker underneath. Reset by + /// and accumulated as the controls are added; reads it when resolving who + /// gets the click. + public bool WantsMouse { get; private set; } + + /// Unique per viewport; keeps ImGui ids from colliding between frames that both + /// use an overlay. + /// Top-left of the image in SCREEN space (what ImGui.GetCursorScreenPos + /// returned just before the image was drawn). + public void Begin(string id, Vector2 viewportPos, Vector2 viewportSize) + { + _restoreCursor = ImGui.GetCursorScreenPos(); + WantsMouse = false; + // Hidden rather than squeezed: a toolbar over a tiny viewport is worse than no toolbar. + _active = viewportSize.X >= ButtonSize * 3f && viewportSize.Y >= ButtonSize * 3f; + if (!_active) return; + + _origin = viewportPos; + _size = viewportSize; + _cursor = viewportPos + new Vector2(Margin, Margin); + _panelTop = _cursor.Y + ButtonSize + Spacing; + _panelColumn = 0; + + ImGui.PushID(id); + // Translucent so the viewport stays readable underneath, and brighter on hover/press so the + // buttons still feel like buttons rather than a watermark. + ImGui.PushStyleColor(ImGuiCol.Button, new Vector4(0.10f, 0.10f, 0.12f, 0.65f)); + ImGui.PushStyleColor(ImGuiCol.ButtonHovered, new Vector4(0.25f, 0.25f, 0.30f, 0.85f)); + ImGui.PushStyleColor(ImGuiCol.ButtonActive, new Vector4(0.35f, 0.35f, 0.42f, 0.95f)); + } + + /// A button that latches. Returns true on the frame it was clicked. + public bool ToggleButton(string label, ref bool state, string? tooltip = null) + { + if (!_active) return false; + + bool clicked; + if (state) + { + ImGui.PushStyleColor(ImGuiCol.Button, new Vector4(0.20f, 0.45f, 0.75f, 0.90f)); + clicked = Button(label, tooltip); + ImGui.PopStyleColor(); + } + else + { + clicked = Button(label, tooltip); + } + + if (clicked) state = !state; + return clicked; + } + + /// A momentary button. Returns true on the frame it was clicked. + public bool Button(string label, string? tooltip = null) + { + if (!_active) return false; + + ImGui.SetCursorScreenPos(_cursor); + bool clicked = ImGui.Button(label, new Vector2(ButtonSize, ButtonSize)); + if (ImGui.IsItemHovered()) + { + WantsMouse = true; + if (tooltip != null) ImGui.SetTooltip(tooltip); + } + + // Remember where a panel opened from this button should hang. + _panelColumn = (int)((_cursor.X - _origin.X - Margin) / (ButtonSize + Spacing)); + _cursor.X += ButtonSize + Spacing; + return clicked; + } + + /// Opens a panel under the toolbar, aligned to the button that was added last. Returns + /// false if there is no room, in which case do NOT call . + public bool BeginPanel(string id, Vector2 size) + { + if (!_active) return false; + + float left = _origin.X + Margin + _panelColumn * (ButtonSize + Spacing); + // Keep the panel inside the viewport, sliding it left if it would overhang the right edge. + float maxWidth = Math.Max(80f, _size.X - Margin * 2f); + float width = size.X <= 0f ? maxWidth : Math.Min(size.X, maxWidth); + left = Math.Min(left, _origin.X + _size.X - Margin - width); + float maxHeight = Math.Max(40f, _size.Y - (_panelTop - _origin.Y) - Margin); + float height = size.Y <= 0f ? 0f : Math.Min(size.Y, maxHeight); + + ImGui.SetCursorScreenPos(new Vector2(left, _panelTop)); + ImGui.PushStyleColor(ImGuiCol.ChildBg, new Vector4(0.08f, 0.08f, 0.10f, 0.92f)); + // AutoResizeY when no explicit height was asked for, so a panel is exactly as tall as the + // widgets in it rather than a guessed constant that has to be maintained by hand. + var flags = ImGuiChildFlags.Borders | (height <= 0f ? ImGuiChildFlags.AutoResizeY : ImGuiChildFlags.None); + bool open = ImGui.BeginChild(id, new Vector2(width, height), flags); + if (!open) + { + ImGui.EndChild(); + ImGui.PopStyleColor(); + } + return open; + } + + public void EndPanel() + { + if (!_active) return; + // Asked while still inside the child, so it answers for the panel rather than the frame's window. + // AllowWhenBlockedByActiveItem keeps a slider being dragged counted as ours even on the frames + // the cursor has wandered off the panel. + if (ImGui.IsWindowHovered(ImGuiHoveredFlags.AllowWhenBlockedByActiveItem)) + WantsMouse = true; + ImGui.EndChild(); + ImGui.PopStyleColor(); + } + + public void End() + { + // Nothing was drawn and the cursor was never moved, so there is nothing to put back. Restoring + // anyway would submit the Dummy below for no reason and nudge the parent's content extent. + if (!_active) return; + + ImGui.PopStyleColor(3); + ImGui.PopID(); + _active = false; + + // Put the layout cursor back where the caller left it, so the overlay cannot disturb whatever + // the frame lays out after the viewport. + // + // The Dummy is required, not decorative. ImGui flags "SetCursorPos used to extend parent + // boundaries" when a window ends with the cursor past CursorMaxPos and no item submitted + // since. The cursor after an Image sits exactly one ItemSpacing.y below CursorMaxPos, so simply + // restoring it trips that assert whenever nothing else follows the viewport (which is the norm: + // gizmos draw through draw lists, not items). Submitting a zero-size item at the restore point + // pulls CursorMaxPos down to it and clears the flag; setting the position again afterwards then + // leaves the cursor exactly where the caller had it, with CursorPos == CursorMaxPos, which the + // check passes. + ImGui.SetCursorScreenPos(_restoreCursor); + ImGui.Dummy(Vector2.Zero); + ImGui.SetCursorScreenPos(_restoreCursor); + } +} diff --git a/ReLunacy/Utility/VramUsageQuery.cs b/ReLunacy/Utility/VramUsageQuery.cs index f758147..7cd112d 100644 --- a/ReLunacy/Utility/VramUsageQuery.cs +++ b/ReLunacy/Utility/VramUsageQuery.cs @@ -6,7 +6,7 @@ namespace ReLunacy.Utility; /// /// Bliss/Veldrith expose no cross-backend GPU memory query, so this reads used VRAM directly /// through Vortice.Vulkan (the same binding library Veldrith's own Vulkan backend is built on, -/// already loaded in-process) via VK_EXT_memory_budget. Vulkan-only — D3D11/D3D12/Metal/OpenGL +/// already loaded in-process) via VK_EXT_memory_budget. Vulkan-only - D3D11/D3D12/Metal/OpenGL /// would each need their own native query and aren't implemented; other backends always read 0. /// internal static unsafe class VramUsageQuery @@ -46,7 +46,7 @@ public static ulong GetUsedVramBytes(GraphicsDevice graphicsDevice) } catch { - // Physical-device query only — safe to keep retrying on transient failure, but + // Physical-device query only - safe to keep retrying on transient failure, but // never let an optional stat readout take the editor down with it. _getMemoryProperties2 = null; return 0;