diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 09328d9..fcfca4e 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -1189,6 +1189,28 @@ public sealed class GameWindow : IDisposable lb.Heightmap, lbXu, lbYu, _heightTable, _blendCtx, _surfaceCache); _terrain.AddLandblock(lb.LandblockId, meshData, origin); + // Compute the per-landblock AABB for frustum culling. XY from the + // landblock's world origin + 192 footprint. Z from the terrain vertex + // range padded +50 above (for trees/buildings) and -10 below (for + // basements). TerrainRenderer already scans vertices internally; we + // replicate here so GpuWorldState has the same bounds for the static + // mesh renderer's culling pass. + { + float zMin = float.MaxValue, zMax = float.MinValue; + foreach (var v in meshData.Vertices) + { + float z = v.Position.Z; + if (z < zMin) zMin = z; + if (z > zMax) zMax = z; + } + if (zMin == float.MaxValue) { zMin = 0f; zMax = 0f; } + zMax += 50f; // generous pad for trees and buildings + zMin -= 10f; // below-ground buffer for basements/cellars + var aabbMin = new System.Numerics.Vector3(origin.X, origin.Y, zMin); + var aabbMax = new System.Numerics.Vector3(origin.X + 192f, origin.Y + 192f, zMax); + _worldState.SetLandblockAabb(lb.LandblockId, aabbMin, aabbMax); + } + // Upload every GfxObj referenced by this landblock's entities. // EnsureUploaded is idempotent so duplicates across landblocks are free. if (_staticMesh is not null) @@ -1320,8 +1342,10 @@ public sealed class GameWindow : IDisposable if (_cameraController is not null) { - _terrain?.Draw(_cameraController.Active); - _staticMesh?.Draw(_cameraController.Active, _worldState.Entities); + var camera = _cameraController.Active; + var frustum = AcDream.App.Rendering.FrustumPlanes.FromViewProjection(camera.View * camera.Projection); + _terrain?.Draw(camera, frustum); + _staticMesh?.Draw(camera, _worldState.LandblockEntries, frustum); } } diff --git a/src/AcDream.App/Rendering/StaticMeshRenderer.cs b/src/AcDream.App/Rendering/StaticMeshRenderer.cs index 135af25..8ba7046 100644 --- a/src/AcDream.App/Rendering/StaticMeshRenderer.cs +++ b/src/AcDream.App/Rendering/StaticMeshRenderer.cs @@ -76,50 +76,57 @@ public sealed unsafe class StaticMeshRenderer : IDisposable }; } - public void Draw(ICamera camera, IEnumerable entities) + public void Draw(ICamera camera, + IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax, IReadOnlyList Entities)> landblockEntries, + FrustumPlanes? frustum = null) { _shader.Use(); _shader.SetMatrix4("uView", camera.View); _shader.SetMatrix4("uProjection", camera.Projection); - // Snapshot entity list once so we iterate it twice (opaque then translucent) - // without re-evaluating lazy enumerables. - var entityList = entities as IReadOnlyList ?? entities.ToList(); - // ── Pass 1: Opaque + ClipMap ────────────────────────────────────────── // Depth write on (default). No blending. ClipMap surfaces use the // alpha-discard path in the fragment shader (uTranslucencyKind == 1). - foreach (var entity in entityList) + foreach (var entry in landblockEntries) { - if (entity.MeshRefs.Count == 0) + // Per-landblock frustum cull: one AABB test skips all entities in + // this landblock if it is fully outside the view frustum. + if (frustum is not null && + !FrustumCuller.IsAabbVisible(frustum.Value, entry.AabbMin, entry.AabbMax)) continue; - foreach (var meshRef in entity.MeshRefs) + foreach (var entity in entry.Entities) { - if (!_gpuByGfxObj.TryGetValue(meshRef.GfxObjId, out var subMeshes)) + if (entity.MeshRefs.Count == 0) continue; - var entityRoot = - Matrix4x4.CreateFromQuaternion(entity.Rotation) * - Matrix4x4.CreateTranslation(entity.Position); - var model = meshRef.PartTransform * entityRoot; - _shader.SetMatrix4("uModel", model); - - foreach (var sub in subMeshes) + foreach (var meshRef in entity.MeshRefs) { - // Skip translucent sub-meshes in the first pass. - if (sub.Translucency != TranslucencyKind.Opaque && - sub.Translucency != TranslucencyKind.ClipMap) + if (!_gpuByGfxObj.TryGetValue(meshRef.GfxObjId, out var subMeshes)) continue; - _shader.SetInt("uTranslucencyKind", (int)sub.Translucency); + var entityRoot = + Matrix4x4.CreateFromQuaternion(entity.Rotation) * + Matrix4x4.CreateTranslation(entity.Position); + var model = meshRef.PartTransform * entityRoot; + _shader.SetMatrix4("uModel", model); - uint tex = ResolveTex(entity, meshRef, sub); - _gl.ActiveTexture(TextureUnit.Texture0); - _gl.BindTexture(TextureTarget.Texture2D, tex); + foreach (var sub in subMeshes) + { + // Skip translucent sub-meshes in the first pass. + if (sub.Translucency != TranslucencyKind.Opaque && + sub.Translucency != TranslucencyKind.ClipMap) + continue; - _gl.BindVertexArray(sub.Vao); - _gl.DrawElements(PrimitiveType.Triangles, (uint)sub.IndexCount, DrawElementsType.UnsignedInt, (void*)0); + _shader.SetInt("uTranslucencyKind", (int)sub.Translucency); + + uint tex = ResolveTex(entity, meshRef, sub); + _gl.ActiveTexture(TextureUnit.Texture0); + _gl.BindTexture(TextureTarget.Texture2D, tex); + + _gl.BindVertexArray(sub.Vao); + _gl.DrawElements(PrimitiveType.Triangles, (uint)sub.IndexCount, DrawElementsType.UnsignedInt, (void*)0); + } } } } @@ -159,55 +166,63 @@ public sealed unsafe class StaticMeshRenderer : IDisposable _gl.CullFace(TriangleFace.Back); _gl.FrontFace(FrontFaceDirection.Ccw); - foreach (var entity in entityList) + foreach (var entry in landblockEntries) { - if (entity.MeshRefs.Count == 0) + // Same per-landblock frustum cull for the translucent pass. + if (frustum is not null && + !FrustumCuller.IsAabbVisible(frustum.Value, entry.AabbMin, entry.AabbMax)) continue; - foreach (var meshRef in entity.MeshRefs) + foreach (var entity in entry.Entities) { - if (!_gpuByGfxObj.TryGetValue(meshRef.GfxObjId, out var subMeshes)) + if (entity.MeshRefs.Count == 0) continue; - var entityRoot = - Matrix4x4.CreateFromQuaternion(entity.Rotation) * - Matrix4x4.CreateTranslation(entity.Position); - var model = meshRef.PartTransform * entityRoot; - _shader.SetMatrix4("uModel", model); - - foreach (var sub in subMeshes) + foreach (var meshRef in entity.MeshRefs) { - if (sub.Translucency == TranslucencyKind.Opaque || - sub.Translucency == TranslucencyKind.ClipMap) + if (!_gpuByGfxObj.TryGetValue(meshRef.GfxObjId, out var subMeshes)) continue; - // Set per-draw blend function. - switch (sub.Translucency) + var entityRoot = + Matrix4x4.CreateFromQuaternion(entity.Rotation) * + Matrix4x4.CreateTranslation(entity.Position); + var model = meshRef.PartTransform * entityRoot; + _shader.SetMatrix4("uModel", model); + + foreach (var sub in subMeshes) { - case TranslucencyKind.Additive: - // src*a + dst — portal swirls, glows - _gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.One); - break; + if (sub.Translucency == TranslucencyKind.Opaque || + sub.Translucency == TranslucencyKind.ClipMap) + continue; - case TranslucencyKind.InvAlpha: - // src*(1-a) + dst*a - _gl.BlendFunc(BlendingFactor.OneMinusSrcAlpha, BlendingFactor.SrcAlpha); - break; + // Set per-draw blend function. + switch (sub.Translucency) + { + case TranslucencyKind.Additive: + // src*a + dst — portal swirls, glows + _gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.One); + break; - default: // AlphaBlend - // src*a + dst*(1-a) - _gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.OneMinusSrcAlpha); - break; + case TranslucencyKind.InvAlpha: + // src*(1-a) + dst*a + _gl.BlendFunc(BlendingFactor.OneMinusSrcAlpha, BlendingFactor.SrcAlpha); + break; + + default: // AlphaBlend + // src*a + dst*(1-a) + _gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.OneMinusSrcAlpha); + break; + } + + _shader.SetInt("uTranslucencyKind", (int)sub.Translucency); + + uint tex = ResolveTex(entity, meshRef, sub); + _gl.ActiveTexture(TextureUnit.Texture0); + _gl.BindTexture(TextureTarget.Texture2D, tex); + + _gl.BindVertexArray(sub.Vao); + _gl.DrawElements(PrimitiveType.Triangles, (uint)sub.IndexCount, DrawElementsType.UnsignedInt, (void*)0); } - - _shader.SetInt("uTranslucencyKind", (int)sub.Translucency); - - uint tex = ResolveTex(entity, meshRef, sub); - _gl.ActiveTexture(TextureUnit.Texture0); - _gl.BindTexture(TextureTarget.Texture2D, tex); - - _gl.BindVertexArray(sub.Vao); - _gl.DrawElements(PrimitiveType.Triangles, (uint)sub.IndexCount, DrawElementsType.UnsignedInt, (void*)0); } } } diff --git a/src/AcDream.App/Rendering/TerrainRenderer.cs b/src/AcDream.App/Rendering/TerrainRenderer.cs index ecd3d49..6c62b76 100644 --- a/src/AcDream.App/Rendering/TerrainRenderer.cs +++ b/src/AcDream.App/Rendering/TerrainRenderer.cs @@ -41,12 +41,24 @@ public sealed unsafe class TerrainRenderer : IDisposable _landblocks.Remove(landblockId); } + float zMin = float.MaxValue, zMax = float.MinValue; + foreach (var v in meshData.Vertices) + { + float z = v.Position.Z; + if (z < zMin) zMin = z; + if (z > zMax) zMax = z; + } + // Fall back to zero if no vertices (shouldn't happen in practice). + if (zMin == float.MaxValue) { zMin = 0f; zMax = 0f; } + var gpu = new LandblockGpu { LandblockId = landblockId, Vao = _gl.GenVertexArray(), WorldOrigin = worldOrigin, IndexCount = meshData.Indices.Length, + MinZ = zMin, + MaxZ = zMax, }; _gl.BindVertexArray(gpu.Vao); @@ -104,7 +116,7 @@ public sealed unsafe class TerrainRenderer : IDisposable _landblocks.Remove(landblockId); } - public void Draw(ICamera camera) + public void Draw(ICamera camera, FrustumPlanes? frustum = null) { _shader.Use(); _shader.SetMatrix4("uView", camera.View); @@ -123,6 +135,14 @@ public sealed unsafe class TerrainRenderer : IDisposable foreach (var lb in _landblocks.Values) { + if (frustum is not null) + { + var aabbMin = new Vector3(lb.WorldOrigin.X, lb.WorldOrigin.Y, lb.MinZ); + var aabbMax = new Vector3(lb.WorldOrigin.X + 192f, lb.WorldOrigin.Y + 192f, lb.MaxZ); + if (!FrustumCuller.IsAabbVisible(frustum.Value, aabbMin, aabbMax)) + continue; + } + var model = Matrix4x4.CreateTranslation(lb.WorldOrigin); _shader.SetMatrix4("uModel", model); _gl.BindVertexArray(lb.Vao); @@ -150,5 +170,7 @@ public sealed unsafe class TerrainRenderer : IDisposable public uint Ebo; public int IndexCount; public Vector3 WorldOrigin; + public float MinZ; + public float MaxZ; } } diff --git a/src/AcDream.App/Streaming/GpuWorldState.cs b/src/AcDream.App/Streaming/GpuWorldState.cs index aa88be9..0b9e910 100644 --- a/src/AcDream.App/Streaming/GpuWorldState.cs +++ b/src/AcDream.App/Streaming/GpuWorldState.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; using System.Linq; +using System.Numerics; using AcDream.Core.World; namespace AcDream.App.Streaming; @@ -38,6 +39,7 @@ namespace AcDream.App.Streaming; public sealed class GpuWorldState { private readonly Dictionary _loaded = new(); + private readonly Dictionary _aabbs = new(); /// /// Per-landblock buffer of live entities awaiting their landblock's @@ -56,6 +58,34 @@ public sealed class GpuWorldState public bool IsLoaded(uint landblockId) => _loaded.ContainsKey(landblockId); + /// + /// Store the axis-aligned bounding box for a loaded landblock. Called from + /// the render thread after the terrain mesh is built and uploaded. + /// + public void SetLandblockAabb(uint landblockId, Vector3 min, Vector3 max) + { + _aabbs[landblockId] = (min, max); + } + + /// + /// Per-landblock iteration with AABB data for use by the frustum-culling + /// draw path. Landblocks without a stored AABB yield + /// for both corners, which the culler will conservatively treat as visible. + /// + public IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax, IReadOnlyList Entities)> LandblockEntries + { + get + { + foreach (var kvp in _loaded) + { + if (_aabbs.TryGetValue(kvp.Key, out var aabb)) + yield return (kvp.Key, aabb.Min, aabb.Max, kvp.Value.Entities); + else + yield return (kvp.Key, Vector3.Zero, Vector3.Zero, kvp.Value.Entities); + } + } + } + /// /// Total live entities currently parked in the pending bucket waiting /// for their landblock to arrive. Useful diagnostic for verifying the @@ -89,6 +119,7 @@ public sealed class GpuWorldState // pending spawns that arrived for it are no longer relevant. The // server will resend them via CreateObject when the player returns. _pendingByLandblock.Remove(landblockId); + _aabbs.Remove(landblockId); if (_loaded.Remove(landblockId)) RebuildFlatView();