acdream/src/AcDream.App/Rendering/StaticMeshRenderer.cs
Erik 192e066182 fix(app+core): Phase B.3 — player cull-exempt, jump height, slope Z
Three user-reported movement fixes:

1. Player disappears when facing away: StaticMeshRenderer now accepts
   an alwaysVisibleEntityId. When a culled landblock contains the
   player entity, it is still drawn. Prevents the frustum culler from
   hiding the player character when they walk far from their spawn
   landblock.

2. Jump too high: JumpImpulse reduced from 10.0 to 3.5 (placeholder;
   retail scales by Jump skill value from the server).

3. Slope Z alignment: replaced the frame-delta slope bias with a
   foot-forward sampling approach — sample terrain Z at 1 unit ahead
   in the walk direction and use max(center, foot) as the ground Z.
   Handles multi-grade slopes where the terrain rises faster than a
   single-point sample tracks.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 19:24:50 +02:00

310 lines
13 KiB
C#

// src/AcDream.App/Rendering/StaticMeshRenderer.cs
using System.Numerics;
using AcDream.Core.Meshing;
using AcDream.Core.Terrain;
using AcDream.Core.World;
using Silk.NET.OpenGL;
namespace AcDream.App.Rendering;
public sealed unsafe class StaticMeshRenderer : IDisposable
{
private readonly GL _gl;
private readonly Shader _shader;
private readonly TextureCache _textures;
// One GPU bundle per unique GfxObj id. Each GfxObj can have multiple sub-meshes.
private readonly Dictionary<uint, List<SubMeshGpu>> _gpuByGfxObj = new();
public StaticMeshRenderer(GL gl, Shader shader, TextureCache textures)
{
_gl = gl;
_shader = shader;
_textures = textures;
}
public void EnsureUploaded(uint gfxObjId, IReadOnlyList<GfxObjSubMesh> subMeshes)
{
if (_gpuByGfxObj.ContainsKey(gfxObjId))
return;
var list = new List<SubMeshGpu>(subMeshes.Count);
foreach (var sm in subMeshes)
list.Add(UploadSubMesh(sm));
_gpuByGfxObj[gfxObjId] = list;
}
private SubMeshGpu UploadSubMesh(GfxObjSubMesh sm)
{
uint vao = _gl.GenVertexArray();
_gl.BindVertexArray(vao);
uint vbo = _gl.GenBuffer();
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, vbo);
fixed (void* p = sm.Vertices)
_gl.BufferData(BufferTargetARB.ArrayBuffer,
(nuint)(sm.Vertices.Length * sizeof(Vertex)), p, BufferUsageARB.StaticDraw);
uint ebo = _gl.GenBuffer();
_gl.BindBuffer(BufferTargetARB.ElementArrayBuffer, ebo);
fixed (void* p = sm.Indices)
_gl.BufferData(BufferTargetARB.ElementArrayBuffer,
(nuint)(sm.Indices.Length * sizeof(uint)), p, BufferUsageARB.StaticDraw);
uint stride = (uint)sizeof(Vertex);
_gl.EnableVertexAttribArray(0);
_gl.VertexAttribPointer(0, 3, VertexAttribPointerType.Float, false, stride, (void*)0);
_gl.EnableVertexAttribArray(1);
_gl.VertexAttribPointer(1, 3, VertexAttribPointerType.Float, false, stride, (void*)(3 * sizeof(float)));
_gl.EnableVertexAttribArray(2);
_gl.VertexAttribPointer(2, 2, VertexAttribPointerType.Float, false, stride, (void*)(6 * sizeof(float)));
_gl.EnableVertexAttribArray(3);
_gl.VertexAttribIPointer(3, 1, VertexAttribIType.UnsignedInt, stride, (void*)(8 * sizeof(float)));
_gl.BindVertexArray(0);
return new SubMeshGpu
{
Vao = vao,
Vbo = vbo,
Ebo = ebo,
IndexCount = sm.Indices.Length,
SurfaceId = sm.SurfaceId,
// Capture translucency at upload time so the draw loop never
// has to look it up from external state.
Translucency = sm.Translucency,
};
}
public void Draw(ICamera camera,
IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax, IReadOnlyList<WorldEntity> Entities)> landblockEntries,
FrustumPlanes? frustum = null,
uint? alwaysVisibleEntityId = null)
{
_shader.Use();
_shader.SetMatrix4("uView", camera.View);
_shader.SetMatrix4("uProjection", camera.Projection);
// ── 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 entry in landblockEntries)
{
// Per-landblock frustum cull: one AABB test skips all entities in
// this landblock if it is fully outside the view frustum.
// Exception: never cull a landblock containing the player entity.
if (frustum is not null &&
!FrustumCuller.IsAabbVisible(frustum.Value, entry.AabbMin, entry.AabbMax))
{
// Check if this landblock contains the always-visible entity.
bool hasAlwaysVisible = false;
if (alwaysVisibleEntityId is not null)
{
foreach (var e in entry.Entities)
if (e.Id == alwaysVisibleEntityId.Value) { hasAlwaysVisible = true; break; }
}
if (!hasAlwaysVisible) continue;
}
foreach (var entity in entry.Entities)
{
if (entity.MeshRefs.Count == 0)
continue;
foreach (var meshRef in entity.MeshRefs)
{
if (!_gpuByGfxObj.TryGetValue(meshRef.GfxObjId, out var subMeshes))
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)
{
// Skip translucent sub-meshes in the first pass.
if (sub.Translucency != TranslucencyKind.Opaque &&
sub.Translucency != TranslucencyKind.ClipMap)
continue;
_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);
}
}
}
}
// ── Pass 2: Translucent (AlphaBlend, Additive, InvAlpha) ─────────────
// Depth test on so translucents composite correctly behind opaque geometry.
// Depth write OFF so translucents don't occlude each other or downstream
// opaque draws. Blend function is set per-draw based on TranslucencyKind.
//
// NOTE: translucent draws are NOT sorted by depth — overlapping translucent
// surfaces can composite in the wrong order. Portal-sized billboards don't
// overlap in practice so this is acceptable and avoids a larger refactor.
_gl.Enable(EnableCap.Blend);
_gl.DepthMask(false);
// Phase 9.2: enable back-face culling for the translucent pass so
// closed-shell translucents (lifestone crystal, glow gems, any
// convex blended mesh) don't draw their back faces over their
// front faces in arbitrary iteration order. Without this, the
// 58 triangles of the lifestone crystal composited with an
// "inside-out" look where the user saw through one face into
// the hollow interior. With back-face culling on, back faces are
// dropped at rasterization time, front faces composite as-is,
// and depth ordering within the front-facing subset is a
// non-issue for closed convex-ish shells. Matches WorldBuilder's
// per-batch CullMode handling in
// references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/
// BaseObjectRenderManager.cs:361-365.
//
// Our fan triangulation emits pos-side polygons as
// (0, i, i+1) which is CCW in standard OpenGL conventions, so
// GL_BACK + CCW front is the correct state. Neg-side polygons
// (if any) use reversed winding and get culled here — that's a
// known limitation and matches the opaque-pass behavior since
// neg-side polys are virtually never translucent in AC content.
_gl.Enable(EnableCap.CullFace);
_gl.CullFace(TriangleFace.Back);
_gl.FrontFace(FrontFaceDirection.Ccw);
foreach (var entry in landblockEntries)
{
// Same per-landblock frustum cull + always-visible exception for pass 2.
if (frustum is not null &&
!FrustumCuller.IsAabbVisible(frustum.Value, entry.AabbMin, entry.AabbMax))
{
bool hasAlwaysVisible = false;
if (alwaysVisibleEntityId is not null)
{
foreach (var e in entry.Entities)
if (e.Id == alwaysVisibleEntityId.Value) { hasAlwaysVisible = true; break; }
}
if (!hasAlwaysVisible) continue;
}
foreach (var entity in entry.Entities)
{
if (entity.MeshRefs.Count == 0)
continue;
foreach (var meshRef in entity.MeshRefs)
{
if (!_gpuByGfxObj.TryGetValue(meshRef.GfxObjId, out var subMeshes))
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)
{
if (sub.Translucency == TranslucencyKind.Opaque ||
sub.Translucency == TranslucencyKind.ClipMap)
continue;
// Set per-draw blend function.
switch (sub.Translucency)
{
case TranslucencyKind.Additive:
// src*a + dst — portal swirls, glows
_gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.One);
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);
}
}
}
}
// Restore default GL state for subsequent renderers (terrain etc.).
_gl.DepthMask(true);
_gl.Disable(EnableCap.Blend);
_gl.Disable(EnableCap.CullFace);
_gl.BindVertexArray(0);
}
/// <summary>
/// Resolves the GL texture id for a sub-mesh, honouring palette and
/// texture overrides carried on the entity and the mesh-ref.
/// </summary>
private uint ResolveTex(WorldEntity entity, MeshRef meshRef, SubMeshGpu sub)
{
uint overrideOrigTex = 0;
bool hasOrigTexOverride = meshRef.SurfaceOverrides is not null
&& meshRef.SurfaceOverrides.TryGetValue(sub.SurfaceId, out overrideOrigTex);
uint? origTexOverride = hasOrigTexOverride ? overrideOrigTex : (uint?)null;
if (entity.PaletteOverride is not null)
{
return _textures.GetOrUploadWithPaletteOverride(
sub.SurfaceId, origTexOverride, entity.PaletteOverride);
}
else if (hasOrigTexOverride)
{
return _textures.GetOrUploadWithOrigTextureOverride(sub.SurfaceId, overrideOrigTex);
}
else
{
return _textures.GetOrUpload(sub.SurfaceId);
}
}
public void Dispose()
{
foreach (var subs in _gpuByGfxObj.Values)
{
foreach (var sub in subs)
{
_gl.DeleteBuffer(sub.Vbo);
_gl.DeleteBuffer(sub.Ebo);
_gl.DeleteVertexArray(sub.Vao);
}
}
_gpuByGfxObj.Clear();
}
private sealed class SubMeshGpu
{
public uint Vao;
public uint Vbo;
public uint Ebo;
public int IndexCount;
public uint SurfaceId;
/// <summary>
/// Cached from GfxObjSubMesh.Translucency at upload time.
/// Avoids any per-draw lookup into external state.
/// </summary>
public TranslucencyKind Translucency;
}
}