diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 2cddb8d..74e2305 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -41,6 +41,15 @@ public sealed class GameWindow : IDisposable private AcDream.Core.Terrain.TerrainBlendingContext? _blendCtx; private Dictionary? _surfaceCache; + // Phase A.1 Task 8: worker thread pre-builds EnvCell room-mesh sub-meshes + // (CPU only) and stores them here. ApplyLoadedTerrain (render thread) drains + // this dict and uploads them via EnsureUploaded before the per-MeshRef loop. + // ConcurrentDictionary is required because the worker and render threads + // access this simultaneously without a broader lock. + private readonly System.Collections.Concurrent.ConcurrentDictionary< + uint, System.Collections.Generic.IReadOnlyList> + _pendingCellMeshes = new(); + /// /// Phase 6.4: per-entity animation playback state for entities whose /// MotionTable resolved to a real cycle. The render loop ticks each @@ -870,10 +879,229 @@ public sealed class GameWindow : IDisposable hydrated.Add(entity); } + // Task 8: merge stabs + scenery + interior into one entity list. + var merged = new List(hydrated); + merged.AddRange(BuildSceneryEntitiesForStreaming(baseLoaded, lbX, lbY)); + merged.AddRange(BuildInteriorEntitiesForStreaming(landblockId, lbX, lbY)); + return new AcDream.Core.World.LoadedLandblock( baseLoaded.LandblockId, baseLoaded.Heightmap, - hydrated); + merged); + } + + /// + /// Phase A.1 Task 8: generate scenery (trees, rocks, bushes) for a single + /// landblock on the worker thread. Pure CPU — no GL calls. + /// + /// Ported from the pre-streaming preload loop in GameWindow.OnLoad + /// (pre-Task-7 version, lines 329-405). Adapted to operate on a single + /// LoadedLandblock instead of iterating worldView.Landblocks. + /// + private List BuildSceneryEntitiesForStreaming( + AcDream.Core.World.LoadedLandblock lb, int lbX, int lbY) + { + var result = new List(); + if (_dats is null || _heightTable is null) return result; + + var region = _dats.Get(0x13000000u); + if (region is null) return result; + + var spawns = AcDream.Core.World.SceneryGenerator.Generate( + _dats, region, lb.Heightmap, lb.LandblockId); + if (spawns.Count == 0) return result; + + var lbOffset = new System.Numerics.Vector3( + (lbX - _liveCenterX) * 192f, + (lbY - _liveCenterY) * 192f, + 0f); + + // Per-landblock id namespace: 0x80000000 | (lbId & 0x00FFFF00) | local_index. + // The landblock coord occupies bits 16-23 (X) and 8-15 (Y) — both fit in the + // 0x00FFFF00 mask. Local index uses bits 0-7 (256 slots per landblock), which + // is enough because SceneryGenerator caps at ~200 spawns per block in practice. + uint sceneryIdBase = 0x80000000u | (lb.LandblockId & 0x00FFFF00u); + uint localIndex = 0; + + foreach (var spawn in spawns) + { + // Resolve the object to a mesh (same GfxObj/Setup logic as Stabs). + // Scale is baked into the root transform by wrapping each part's + // transform with a scale matrix. + var meshRefs = new List(); + var scaleMat = System.Numerics.Matrix4x4.CreateScale(spawn.Scale); + + if ((spawn.ObjectId & 0xFF000000u) == 0x01000000u) + { + var gfx = _dats.Get(spawn.ObjectId); + if (gfx is not null) + { + // Sub-meshes pre-built CPU-side; upload deferred to ApplyLoadedTerrain. + _ = AcDream.Core.Meshing.GfxObjMesh.Build(gfx, _dats); + meshRefs.Add(new AcDream.Core.World.MeshRef(spawn.ObjectId, scaleMat)); + } + } + else if ((spawn.ObjectId & 0xFF000000u) == 0x02000000u) + { + var setup = _dats.Get(spawn.ObjectId); + if (setup is not null) + { + var flat = AcDream.Core.Meshing.SetupMesh.Flatten(setup); + foreach (var mr in flat) + { + var gfx = _dats.Get(mr.GfxObjId); + if (gfx is null) continue; + _ = AcDream.Core.Meshing.GfxObjMesh.Build(gfx, _dats); + // Compose: part's own transform, then the spawn's scale. + meshRefs.Add(new AcDream.Core.World.MeshRef(mr.GfxObjId, mr.PartTransform * scaleMat)); + } + } + } + + if (meshRefs.Count == 0) continue; + + // Sample terrain Z at (localX, localY) to lift scenery onto the ground. + float localX = spawn.LocalPosition.X; + float localY = spawn.LocalPosition.Y; + float groundZ = SampleTerrainZ(lb.Heightmap, _heightTable, localX, localY); + + var hydrated = new AcDream.Core.World.WorldEntity + { + Id = sceneryIdBase + localIndex++, + SourceGfxObjOrSetupId = spawn.ObjectId, + Position = new System.Numerics.Vector3(localX, localY, groundZ) + lbOffset, + Rotation = spawn.Rotation, + MeshRefs = meshRefs, + }; + result.Add(hydrated); + } + + return result; + } + + /// + /// Phase A.1 Task 8: walk a landblock's EnvCells and produce (a) the cell + /// room-mesh entity (Phase 7.1) for each EnvCell with an EnvironmentId, and + /// (b) a WorldEntity per StaticObject in each cell. Pure CPU — no GL calls. + /// + /// Cell sub-meshes are stored in _pendingCellMeshes (ConcurrentDictionary) + /// so ApplyLoadedTerrain can drain + upload them on the render thread. + /// + /// Ported from pre-streaming preload lines 407-565. + /// + private List BuildInteriorEntitiesForStreaming( + uint landblockId, int lbX, int lbY) + { + var result = new List(); + if (_dats is null) return result; + + var lbInfo = _dats.Get((landblockId & 0xFFFF0000u) | 0xFFFEu); + if (lbInfo is null || lbInfo.NumCells == 0) return result; + + var lbOffset = new System.Numerics.Vector3( + (lbX - _liveCenterX) * 192f, + (lbY - _liveCenterY) * 192f, + 0f); + + // Per-landblock id namespace: 0x40000000 | (lbId & 0x00FFFF00) | local_counter. + // Distinct from scenery (0x80000000+) and stabs (ids from LandblockLoader). + uint interiorIdBase = 0x40000000u | (landblockId & 0x00FFFF00u); + uint localCounter = 0; + + uint firstCellId = (landblockId & 0xFFFF0000u) | 0x0100u; + for (uint offset = 0; offset < lbInfo.NumCells; offset++) + { + uint envCellId = firstCellId + offset; + var envCell = _dats.Get(envCellId); + if (envCell is null) continue; + + // Phase 7.1: build and register room geometry for this EnvCell. + if (envCell.EnvironmentId != 0) + { + var environment = _dats.Get(0x0D000000u | envCell.EnvironmentId); + if (environment is not null + && environment.Cells.TryGetValue(envCell.CellStructure, out var cellStruct)) + { + var cellSubMeshes = AcDream.Core.Meshing.CellMesh.Build(envCell, cellStruct, _dats); + if (cellSubMeshes.Count > 0) + { + // Store in the pending dict so ApplyLoadedTerrain can upload on + // the render thread. The key is the EnvCell dat id — same key + // used in the MeshRef below so EnsureUploaded can find it. + _pendingCellMeshes[envCellId] = cellSubMeshes; + + // Z lift: 2 cm to avoid depth-fighting with terrain polygon. + var cellOrigin = envCell.Position.Origin + lbOffset + + new System.Numerics.Vector3(0f, 0f, 0.02f); + var cellTransform = + System.Numerics.Matrix4x4.CreateFromQuaternion(envCell.Position.Orientation) * + System.Numerics.Matrix4x4.CreateTranslation(cellOrigin); + + var cellMeshRef = new AcDream.Core.World.MeshRef(envCellId, cellTransform); + + var cellEntity = new AcDream.Core.World.WorldEntity + { + Id = interiorIdBase + localCounter++, + SourceGfxObjOrSetupId = envCellId, + Position = System.Numerics.Vector3.Zero, + Rotation = System.Numerics.Quaternion.Identity, + MeshRefs = new[] { cellMeshRef }, + }; + result.Add(cellEntity); + } + } + } + + // Phase 2d: static objects inside the EnvCell. + foreach (var stab in envCell.StaticObjects) + { + var meshRefs = new List(); + if ((stab.Id & 0xFF000000u) == 0x01000000u) + { + var gfx = _dats.Get(stab.Id); + if (gfx is not null) + { + _ = AcDream.Core.Meshing.GfxObjMesh.Build(gfx, _dats); + meshRefs.Add(new AcDream.Core.World.MeshRef(stab.Id, System.Numerics.Matrix4x4.Identity)); + } + } + else if ((stab.Id & 0xFF000000u) == 0x02000000u) + { + var setup = _dats.Get(stab.Id); + if (setup is not null) + { + var flat = AcDream.Core.Meshing.SetupMesh.Flatten(setup); + foreach (var mr in flat) + { + var gfx = _dats.Get(mr.GfxObjId); + if (gfx is null) continue; + _ = AcDream.Core.Meshing.GfxObjMesh.Build(gfx, _dats); + meshRefs.Add(mr); + } + } + } + + if (meshRefs.Count == 0) continue; + + // Stabs inside EnvCells are already in landblock-local coordinates + // (same space as LandBlockInfo.Objects stabs). Adding cellOrigin would + // be wrong — see Phase 2d comment in the pre-streaming preload. + var worldPos = stab.Frame.Origin + lbOffset; + var worldRot = stab.Frame.Orientation; + + var hydrated = new AcDream.Core.World.WorldEntity + { + Id = interiorIdBase + localCounter++, + SourceGfxObjOrSetupId = stab.Id, + Position = worldPos, + Rotation = worldRot, + MeshRefs = meshRefs, + }; + result.Add(hydrated); + } + } + + return result; } /// @@ -906,10 +1134,32 @@ public sealed class GameWindow : IDisposable // EnsureUploaded is idempotent so duplicates across landblocks are free. if (_staticMesh is not null) { + // Task 8: drain any pending EnvCell room-mesh sub-meshes first. + // The worker thread pre-built these CPU-side and stored them in + // _pendingCellMeshes. We must upload them here (render thread) before + // the per-MeshRef loop below tries to look them up via GfxObjMesh.Build, + // which would fail because EnvCell ids (0xAAAA01xx) aren't real GfxObj + // dat ids. EnsureUploaded is idempotent so calling it here then seeing + // the same id again in the loop below is safe. foreach (var entity in lb.Entities) { foreach (var meshRef in entity.MeshRefs) { + if (_pendingCellMeshes.TryRemove(meshRef.GfxObjId, out var cellSubMeshes)) + _staticMesh.EnsureUploaded(meshRef.GfxObjId, cellSubMeshes); + } + } + + // Now upload regular GfxObj sub-meshes (stabs, scenery, interior stabs). + // Skip any ids already uploaded (includes the cell meshes just drained). + foreach (var entity in lb.Entities) + { + foreach (var meshRef in entity.MeshRefs) + { + // Skip EnvCell synthetic ids — already handled above (or already + // uploaded on a prior tick). GfxObj ids are 0x01xxxxxx; Setup ids + // are 0x02xxxxxx; anything else is not a GfxObj dat record. + if ((meshRef.GfxObjId & 0xFF000000u) != 0x01000000u) continue; var gfx = _dats.Get(meshRef.GfxObjId); if (gfx is null) continue; var subMeshes = AcDream.Core.Meshing.GfxObjMesh.Build(gfx, _dats);