feat(core+app): per-cell terrain texture blending (Phase 3c.4)
The visual-win commit that wires up the Phase 3c.1/.2/.3 building blocks:
Holtburg's terrain now uses AC's real per-cell texture-merge blend
(base + up to 3 terrain overlays + up to 2 road overlays, with alpha
masks from the alpha atlas) instead of the flat per-vertex single-layer
atlas lookup that preceded it.
Geometry rewrite:
- New TerrainVertex struct (40 bytes): Position(vec3) + Normal(vec3) +
Data0..3 (4x uint32 packed blend recipe)
- LandblockMesh.Build is now cell-based: iterates 8x8 cells instead of
the old 9x9 vertex grid, emits 6 vertices per cell (two triangles),
384 total vertices per landblock
- For each cell: extract 4-corner terrain/road values → GetPalCode →
BuildSurface (cached across landblocks via a shared surfaceCache) →
FillCellData → split direction from CalculateSplitDirection → emit
6 vertices in the exact gl_VertexID % 6 order WorldBuilder's vertex
shader expects
- Per-vertex normals preserved via Phase 3b central-difference
precomputation on the 9x9 heightmap, interpolated smoothly across
the cell (we deliberately didn't adopt WorldBuilder's dFdx/dFdy
flat-shade approach — Phase 3a/3b user-tuned lighting was worth
keeping)
Renderer rewrite:
- TerrainRenderer VAO: vec3 Position, vec3 Normal, 4x uvec4 byte
attributes for Data0..3. The uvec4-of-bytes read pattern matches
Landscape.vert so the ported shader math stays byte-for-byte
identical to WorldBuilder's.
- Binds both atlases: terrain atlas on unit 0 (uTerrain), alpha atlas
on unit 1 (uAlpha)
Shader rewrite (ports of WorldBuilder Landscape.vert/.frag, trimmed):
- terrain.vert: unpacks the 4 data bytes + rotation bits, derives the
cell corner from gl_VertexID % 6 + splitDir, rotates the cell-local
UV per overlay's rotation field, and computes world-space normal
for the fragment shader
- terrain.frag: maskBlend3 three-layer alpha-weighted composite for
terrain overlays, inverted-alpha road combine, final composite
base * (1-ovlA)*(1-rdA) + ovl * ovlA*(1-rdA) + road * rdA. Phase
3a/3b directional lighting applied on top (SUN_DIR, AMBIENT=0.25,
DIFFUSE=0.75, in sync with mesh.frag).
- Editor uniforms (grid, brush, unwalkable slopes) deliberately
omitted — not applicable to a game client
- Per-texture tiling factor hardcoded to 1.0 for now (WorldBuilder
reads it from uTexTiling[36] uploaded from the dats); one tile per
cell = 8 tiles per landblock-side, slightly coarser than the old
~2x-per-cell tiling. Tunable via the TILE constant if needed.
TerrainAtlas grew parallel TCode/RCode lists (CornerAlphaTCodes,
SideAlphaTCodes, RoadAlphaRCodes) so TerrainBlendingContext can be
built without the mesh loader touching the dats directly.
GameWindow builds a TerrainBlendingContext once, shares a Dictionary
<uint, SurfaceInfo> surfaceCache across all 9 landblocks. Output:
"terrain: 137 unique palette codes across 9 landblocks" — avg ~15
unique per landblock, cache reuse healthy.
LandblockMeshTests rewritten for 384-vertex layout. 77/77 tests green.
Visual smoke run launches clean: no shader compile/link errors, no
GL warnings, terrain renders to the screen.
User visual verification is the final acceptance gate for Phase 3c.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
a6cd56663f
commit
e0dfecdf23
8 changed files with 610 additions and 170 deletions
|
|
@ -149,21 +149,47 @@ public sealed class GameWindow : IDisposable
|
|||
int centerX = (int)((centerLandblockId >> 24) & 0xFFu);
|
||||
int centerY = (int)((centerLandblockId >> 16) & 0xFFu);
|
||||
|
||||
// Shared blending context + SurfaceInfo cache across all loaded
|
||||
// landblocks. Palette codes are deterministic so two landblocks that
|
||||
// happen to share a cell layout hit the cache instead of rebuilding.
|
||||
var terrainTypeToLayerBytes = new Dictionary<uint, byte>(terrainAtlas.TerrainTypeToLayer.Count);
|
||||
foreach (var kvp in terrainAtlas.TerrainTypeToLayer)
|
||||
terrainTypeToLayerBytes[kvp.Key] = (byte)kvp.Value;
|
||||
|
||||
const uint RoadTypeEnumValue = 0x20; // TerrainTextureType.RoadType
|
||||
byte roadLayer = terrainTypeToLayerBytes.TryGetValue(RoadTypeEnumValue, out var rl)
|
||||
? rl
|
||||
: AcDream.Core.Terrain.SurfaceInfo.None;
|
||||
|
||||
var blendCtx = new AcDream.Core.Terrain.TerrainBlendingContext(
|
||||
TerrainTypeToLayer: terrainTypeToLayerBytes,
|
||||
RoadLayer: roadLayer,
|
||||
CornerAlphaLayers: terrainAtlas.CornerAlphaLayers,
|
||||
SideAlphaLayers: terrainAtlas.SideAlphaLayers,
|
||||
RoadAlphaLayers: terrainAtlas.RoadAlphaLayers,
|
||||
CornerAlphaTCodes: terrainAtlas.CornerAlphaTCodes,
|
||||
SideAlphaTCodes: terrainAtlas.SideAlphaTCodes,
|
||||
RoadAlphaRCodes: terrainAtlas.RoadAlphaRCodes);
|
||||
|
||||
var surfaceCache = new Dictionary<uint, AcDream.Core.Terrain.SurfaceInfo>();
|
||||
|
||||
foreach (var lb in worldView.Landblocks)
|
||||
{
|
||||
uint lbX = (lb.LandblockId >> 24) & 0xFFu;
|
||||
uint lbY = (lb.LandblockId >> 16) & 0xFFu;
|
||||
|
||||
var meshData = AcDream.Core.Terrain.LandblockMesh.Build(
|
||||
lb.Heightmap, heightTable, terrainAtlas.TerrainTypeToLayer);
|
||||
lb.Heightmap, lbX, lbY, heightTable, blendCtx, surfaceCache);
|
||||
|
||||
// Compute world origin for this landblock relative to the center.
|
||||
int lbX = (int)((lb.LandblockId >> 24) & 0xFFu);
|
||||
int lbY = (int)((lb.LandblockId >> 16) & 0xFFu);
|
||||
var origin = new System.Numerics.Vector3(
|
||||
(lbX - centerX) * 192f,
|
||||
(lbY - centerY) * 192f,
|
||||
((int)lbX - centerX) * 192f,
|
||||
((int)lbY - centerY) * 192f,
|
||||
0f);
|
||||
|
||||
_terrain.AddLandblock(meshData, origin);
|
||||
}
|
||||
Console.WriteLine($"terrain: {surfaceCache.Count} unique palette codes across {worldView.Landblocks.Count} landblocks");
|
||||
|
||||
_textureCache = new TextureCache(_gl, _dats);
|
||||
_staticMesh = new StaticMeshRenderer(_gl, _meshShader, _textureCache);
|
||||
|
|
|
|||
|
|
@ -1,23 +1,127 @@
|
|||
#version 430 core
|
||||
in vec2 vTex;
|
||||
in flat uint vLayer;
|
||||
in vec3 vWorldNormal;
|
||||
// Per-cell terrain blending (Phase 3c.4) — ported from WorldBuilder's
|
||||
// Landscape.frag, trimmed of editor-specific features (grid, brush,
|
||||
// walkable-slope highlighting) and with Phase 3a/3b directional lighting
|
||||
// layered on at the end.
|
||||
|
||||
in vec2 vBaseUV;
|
||||
in vec3 vWorldNormal;
|
||||
in vec4 vOverlay0;
|
||||
in vec4 vOverlay1;
|
||||
in vec4 vOverlay2;
|
||||
in vec4 vRoad0;
|
||||
in vec4 vRoad1;
|
||||
flat in float vBaseTexIdx;
|
||||
|
||||
out vec4 fragColor;
|
||||
|
||||
uniform sampler2DArray uAtlas;
|
||||
uniform sampler2DArray uTerrain; // 33+ layers — TerrainAtlas.GlTexture
|
||||
uniform sampler2DArray uAlpha; // 8+ layers — TerrainAtlas.GlAlphaTexture
|
||||
|
||||
// Shared lighting model with mesh.frag — must stay in sync. Phase 3b gave
|
||||
// terrain real per-vertex normals via central differences on the heightmap,
|
||||
// so hills catch and shade the sun just like static meshes do.
|
||||
const vec3 SUN_DIR = normalize(vec3(0.5, 0.4, 0.6));
|
||||
// Phase 3a lighting (in sync with mesh.frag — update both together).
|
||||
const vec3 SUN_DIR = normalize(vec3(0.5, 0.4, 0.6));
|
||||
const float AMBIENT = 0.25;
|
||||
const float DIFFUSE = 0.75;
|
||||
|
||||
void main() {
|
||||
vec4 sampled = texture(uAtlas, vec3(vTex, float(vLayer)));
|
||||
// Per-texture tiling repeat count across a cell. WorldBuilder uses
|
||||
// uTexTiling[36] uploaded from the dats; we default to 1.0 (one tile per
|
||||
// cell, 8 tiles across a landblock) until we wire the array. The previous
|
||||
// Phase 2b/3 single-layer path tiled at ~2 per cell, so the world may read
|
||||
// slightly coarser at 1.0 — tunable here if it looks wrong.
|
||||
const float TILE = 1.0;
|
||||
|
||||
// Three-layer alpha-weighted composite. Each terrain overlay layer
|
||||
// contributes based on its own alpha mask; missing layers (h == 0) collapse
|
||||
// to transparent. Lifted verbatim from WorldBuilder's Landscape.frag.
|
||||
vec4 maskBlend3(vec4 t0, vec4 t1, vec4 t2, float h0, float h1, float h2) {
|
||||
float a0 = h0 == 0.0 ? 1.0 : t0.a;
|
||||
float a1 = h1 == 0.0 ? 1.0 : t1.a;
|
||||
float a2 = h2 == 0.0 ? 1.0 : t2.a;
|
||||
float aR = 1.0 - (a0 * a1 * a2);
|
||||
// avoid divide-by-zero when all three overlays are absent
|
||||
float aRsafe = max(aR, 1e-6);
|
||||
a0 = 1.0 - a0;
|
||||
a1 = 1.0 - a1;
|
||||
a2 = 1.0 - a2;
|
||||
vec3 r0 = (a0 * t0.rgb + (1.0 - a0) * a1 * t1.rgb + (1.0 - a1) * a2 * t2.rgb);
|
||||
return vec4(r0 / aRsafe, aR);
|
||||
}
|
||||
|
||||
vec4 combineOverlays(vec2 baseUV, vec4 pOverlay0, vec4 pOverlay1, vec4 pOverlay2) {
|
||||
float h0 = pOverlay0.z < 0.0 ? 0.0 : 1.0;
|
||||
float h1 = pOverlay1.z < 0.0 ? 0.0 : 1.0;
|
||||
float h2 = pOverlay2.z < 0.0 ? 0.0 : 1.0;
|
||||
vec4 t0 = vec4(0.0), t1 = vec4(0.0), t2 = vec4(0.0);
|
||||
|
||||
if (h0 > 0.0) {
|
||||
t0 = texture(uTerrain, vec3(baseUV * TILE, pOverlay0.z));
|
||||
if (pOverlay0.w >= 0.0) {
|
||||
vec4 a = texture(uAlpha, vec3(pOverlay0.xy, pOverlay0.w));
|
||||
t0.a = a.a;
|
||||
}
|
||||
}
|
||||
if (h1 > 0.0) {
|
||||
t1 = texture(uTerrain, vec3(baseUV * TILE, pOverlay1.z));
|
||||
if (pOverlay1.w >= 0.0) {
|
||||
vec4 a = texture(uAlpha, vec3(pOverlay1.xy, pOverlay1.w));
|
||||
t1.a = a.a;
|
||||
}
|
||||
}
|
||||
if (h2 > 0.0) {
|
||||
t2 = texture(uTerrain, vec3(baseUV * TILE, pOverlay2.z));
|
||||
if (pOverlay2.w >= 0.0) {
|
||||
vec4 a = texture(uAlpha, vec3(pOverlay2.xy, pOverlay2.w));
|
||||
t2.a = a.a;
|
||||
}
|
||||
}
|
||||
return maskBlend3(t0, t1, t2, h0, h1, h2);
|
||||
}
|
||||
|
||||
vec4 combineRoad(vec2 baseUV, vec4 pRoad0, vec4 pRoad1) {
|
||||
float h0 = pRoad0.z < 0.0 ? 0.0 : 1.0;
|
||||
float h1 = pRoad1.z < 0.0 ? 0.0 : 1.0;
|
||||
vec4 result = vec4(0.0);
|
||||
if (h0 > 0.0) {
|
||||
result = texture(uTerrain, vec3(baseUV * TILE, pRoad0.z));
|
||||
if (pRoad0.w >= 0.0) {
|
||||
vec4 a0 = texture(uAlpha, vec3(pRoad0.xy, pRoad0.w));
|
||||
// Roads use inverted alpha (the mask stores NON-road coverage).
|
||||
result.a = 1.0 - a0.a;
|
||||
if (h1 > 0.0 && pRoad1.w >= 0.0) {
|
||||
vec4 a1 = texture(uAlpha, vec3(pRoad1.xy, pRoad1.w));
|
||||
result.a = 1.0 - (a0.a * a1.a);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
void main() {
|
||||
// Base color: if there's no base layer (sentinel -1) just render black
|
||||
// (shouldn't happen in valid data).
|
||||
vec4 baseColor = vec4(0.0);
|
||||
if (vBaseTexIdx >= 0.0) {
|
||||
baseColor = texture(uTerrain, vec3(vBaseUV * TILE, vBaseTexIdx));
|
||||
}
|
||||
|
||||
vec4 overlays = vec4(0.0);
|
||||
if (vOverlay0.z >= 0.0)
|
||||
overlays = combineOverlays(vBaseUV, vOverlay0, vOverlay1, vOverlay2);
|
||||
|
||||
vec4 roads = vec4(0.0);
|
||||
if (vRoad0.z >= 0.0)
|
||||
roads = combineRoad(vBaseUV, vRoad0, vRoad1);
|
||||
|
||||
// Composite: base × (1 - ovlA) × (1 - rdA) + ovl × ovlA × (1 - rdA) + road × rdA
|
||||
vec3 baseMasked = baseColor.rgb * ((1.0 - overlays.a) * (1.0 - roads.a));
|
||||
vec3 ovlMasked = overlays.rgb * (overlays.a * (1.0 - roads.a));
|
||||
vec3 roadMasked = roads.rgb * roads.a;
|
||||
vec3 rgb = clamp(baseMasked + ovlMasked + roadMasked, 0.0, 1.0);
|
||||
|
||||
// Phase 3a/3b directional lighting (in sync with mesh.frag constants).
|
||||
vec3 N = normalize(vWorldNormal);
|
||||
float ndotl = max(dot(N, SUN_DIR), 0.0);
|
||||
float lighting = AMBIENT + DIFFUSE * ndotl;
|
||||
fragColor = vec4(sampled.rgb * lighting, sampled.a);
|
||||
|
||||
fragColor = vec4(rgb * lighting, 1.0);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,23 +1,105 @@
|
|||
#version 430 core
|
||||
layout(location = 0) in vec3 aPos;
|
||||
layout(location = 1) in vec3 aNormal;
|
||||
layout(location = 2) in vec2 aTex;
|
||||
layout(location = 3) in uint aTerrainLayer;
|
||||
layout(location = 0) in vec3 aPos;
|
||||
layout(location = 1) in vec3 aNormal;
|
||||
layout(location = 2) in uvec4 aPacked0; // bytes: baseTex, baseAlpha(255), ovl0Tex, ovl0Alpha
|
||||
layout(location = 3) in uvec4 aPacked1; // bytes: ovl1Tex, ovl1Alpha, ovl2Tex, ovl2Alpha
|
||||
layout(location = 4) in uvec4 aPacked2; // bytes: road0Tex, road0Alpha, road1Tex, road1Alpha
|
||||
layout(location = 5) in uvec4 aPacked3; // bits: rot fields + splitDir (see below)
|
||||
|
||||
uniform mat4 uModel;
|
||||
uniform mat4 uView;
|
||||
uniform mat4 uProjection;
|
||||
|
||||
out vec2 vTex;
|
||||
out flat uint vLayer;
|
||||
out vec3 vWorldNormal;
|
||||
out vec2 vBaseUV;
|
||||
out vec3 vWorldNormal;
|
||||
// Per-layer "UV.xy in cell-local 0..1 space, tex index .z, alpha index .w".
|
||||
// Negative .z means "layer not present, skip it in the fragment shader."
|
||||
out vec4 vOverlay0;
|
||||
out vec4 vOverlay1;
|
||||
out vec4 vOverlay2;
|
||||
out vec4 vRoad0;
|
||||
out vec4 vRoad1;
|
||||
flat out float vBaseTexIdx;
|
||||
|
||||
// Port of WorldBuilder's Landscape.vert unpackOverlayLayer: sentinel-check
|
||||
// 255 → -1 (shader skips), then rotate the cell-local UV by the overlay's
|
||||
// 90° rotation count.
|
||||
vec4 unpackOverlayLayer(uint texIdxU, uint alphaIdxU, uint rotIdx, vec2 baseUV) {
|
||||
float texIdx = float(texIdxU);
|
||||
float alphaIdx = float(alphaIdxU);
|
||||
if (texIdx >= 254.0) texIdx = -1.0;
|
||||
if (alphaIdx >= 254.0) alphaIdx = -1.0;
|
||||
|
||||
vec2 rotatedUV = baseUV;
|
||||
if (rotIdx == 1u) rotatedUV = vec2(1.0 - baseUV.y, baseUV.x);
|
||||
else if (rotIdx == 2u) rotatedUV = vec2(1.0 - baseUV.x, 1.0 - baseUV.y);
|
||||
else if (rotIdx == 3u) rotatedUV = vec2( baseUV.y, 1.0 - baseUV.x);
|
||||
|
||||
return vec4(rotatedUV.x, rotatedUV.y, texIdx, alphaIdx);
|
||||
}
|
||||
|
||||
void main() {
|
||||
vTex = aTex;
|
||||
vLayer = aTerrainLayer;
|
||||
// uModel for terrain is a pure translation so mat3(uModel) is identity
|
||||
// and vWorldNormal == aNormal. Computing it the uniform way anyway so
|
||||
// later world-rotated landblocks (if any) still work.
|
||||
// Unpack rotation fields from aPacked3. Bit layout (data3):
|
||||
// .x (byte 0): bits 0-1 rotBase (unused), 2-3 rotOvl0, 4-5 rotOvl1, 6-7 rotOvl2
|
||||
// .y (byte 1): bits 0-1 rotRd0 (= data3 bit 8-9),
|
||||
// bits 2-3 rotRd1 (= data3 bit 10-11),
|
||||
// bit 4 splitDir (= data3 bit 12)
|
||||
uint rotOvl0 = (aPacked3.x >> 2u) & 3u;
|
||||
uint rotOvl1 = (aPacked3.x >> 4u) & 3u;
|
||||
uint rotOvl2 = (aPacked3.x >> 6u) & 3u;
|
||||
uint rotRd0 = aPacked3.y & 3u;
|
||||
uint rotRd1 = (aPacked3.y >> 2u) & 3u;
|
||||
uint splitDir= (aPacked3.y >> 4u) & 1u;
|
||||
|
||||
// Derive which of the 4 cell corners this vertex represents from
|
||||
// gl_VertexID % 6. The CPU-side LandblockMesh emits vertices in a
|
||||
// specific order for each split direction; the table below must stay
|
||||
// in lockstep with LandblockMesh.Build's SWtoNE/SEtoNW branches.
|
||||
//
|
||||
// Corner labels: 0=BL (low x/y), 1=BR (high x, low y),
|
||||
// 2=TR (high x/y), 3=TL (low x, high y).
|
||||
// WorldBuilder assigns cell-local UV per corner:
|
||||
// 0 → (0, 1) 1 → (1, 1) 2 → (1, 0) 3 → (0, 0)
|
||||
// (the v axis is flipped vs. geometric convention — harmless, just a
|
||||
// texture-space choice).
|
||||
int vIdx = gl_VertexID % 6;
|
||||
int corner = 0;
|
||||
if (splitDir == 0u) {
|
||||
// SWtoNE order: BL, TL, BR, BR, TL, TR → corners 0, 3, 1, 1, 3, 2
|
||||
if (vIdx == 0) corner = 0;
|
||||
else if (vIdx == 1) corner = 3;
|
||||
else if (vIdx == 2) corner = 1;
|
||||
else if (vIdx == 3) corner = 1;
|
||||
else if (vIdx == 4) corner = 3;
|
||||
else corner = 2;
|
||||
} else {
|
||||
// SEtoNW order: BL, TR, BR, BL, TL, TR → corners 0, 2, 1, 0, 3, 2
|
||||
if (vIdx == 0) corner = 0;
|
||||
else if (vIdx == 1) corner = 2;
|
||||
else if (vIdx == 2) corner = 1;
|
||||
else if (vIdx == 3) corner = 0;
|
||||
else if (vIdx == 4) corner = 3;
|
||||
else corner = 2;
|
||||
}
|
||||
|
||||
vec2 baseUV;
|
||||
if (corner == 0) baseUV = vec2(0.0, 1.0);
|
||||
else if (corner == 1) baseUV = vec2(1.0, 1.0);
|
||||
else if (corner == 2) baseUV = vec2(1.0, 0.0);
|
||||
else baseUV = vec2(0.0, 0.0);
|
||||
|
||||
vBaseUV = baseUV;
|
||||
vWorldNormal = normalize(mat3(uModel) * aNormal);
|
||||
|
||||
float baseTex = float(aPacked0.x);
|
||||
if (baseTex >= 254.0) baseTex = -1.0;
|
||||
vBaseTexIdx = baseTex;
|
||||
|
||||
vOverlay0 = unpackOverlayLayer(aPacked0.z, aPacked0.w, rotOvl0, baseUV);
|
||||
vOverlay1 = unpackOverlayLayer(aPacked1.x, aPacked1.y, rotOvl1, baseUV);
|
||||
vOverlay2 = unpackOverlayLayer(aPacked1.z, aPacked1.w, rotOvl2, baseUV);
|
||||
vRoad0 = unpackOverlayLayer(aPacked2.x, aPacked2.y, rotRd0, baseUV);
|
||||
vRoad1 = unpackOverlayLayer(aPacked2.z, aPacked2.w, rotRd1, baseUV);
|
||||
|
||||
gl_Position = uProjection * uView * uModel * vec4(aPos, 1.0);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,26 +38,27 @@ public sealed unsafe class TerrainAtlas : IDisposable
|
|||
// --- Alpha atlas (new in Phase 3c.2) ---
|
||||
public uint GlAlphaTexture { get; }
|
||||
public int AlphaLayerCount { get; }
|
||||
/// <summary>
|
||||
/// Layer indices in the alpha atlas for CornerTerrainMaps (typically 4 entries).
|
||||
/// Matches WorldBuilder's convention that corner alpha indices start at 0.
|
||||
/// </summary>
|
||||
/// <summary>Layer indices in the alpha atlas for CornerTerrainMaps (typically 4 entries).</summary>
|
||||
public IReadOnlyList<byte> CornerAlphaLayers { get; }
|
||||
/// <summary>
|
||||
/// Layer indices in the alpha atlas for SideTerrainMaps (typically 4 entries).
|
||||
/// WorldBuilder convention: side indices start at 4.
|
||||
/// </summary>
|
||||
/// <summary>Layer indices in the alpha atlas for SideTerrainMaps (typically 4 entries).</summary>
|
||||
public IReadOnlyList<byte> SideAlphaLayers { get; }
|
||||
/// <summary>
|
||||
/// Layer indices in the alpha atlas for RoadMaps (variable count, typically ~10).
|
||||
/// </summary>
|
||||
/// <summary>Layer indices in the alpha atlas for RoadMaps (variable count).</summary>
|
||||
public IReadOnlyList<byte> RoadAlphaLayers { get; }
|
||||
|
||||
// --- Parallel TCode/RCode arrays (added in Phase 3c.4 for BuildSurface) ---
|
||||
/// <summary>TCode for each CornerTerrainMap, parallel to <see cref="CornerAlphaLayers"/>.</summary>
|
||||
public IReadOnlyList<uint> CornerAlphaTCodes { get; }
|
||||
/// <summary>TCode for each SideTerrainMap, parallel to <see cref="SideAlphaLayers"/>.</summary>
|
||||
public IReadOnlyList<uint> SideAlphaTCodes { get; }
|
||||
/// <summary>RCode for each RoadMap, parallel to <see cref="RoadAlphaLayers"/>.</summary>
|
||||
public IReadOnlyList<uint> RoadAlphaRCodes { get; }
|
||||
|
||||
private TerrainAtlas(
|
||||
GL gl,
|
||||
uint glTexture, IReadOnlyDictionary<uint, uint> map, int layerCount,
|
||||
uint glAlphaTexture, int alphaLayerCount,
|
||||
IReadOnlyList<byte> cornerLayers, IReadOnlyList<byte> sideLayers, IReadOnlyList<byte> roadLayers)
|
||||
IReadOnlyList<byte> cornerLayers, IReadOnlyList<byte> sideLayers, IReadOnlyList<byte> roadLayers,
|
||||
IReadOnlyList<uint> cornerTCodes, IReadOnlyList<uint> sideTCodes, IReadOnlyList<uint> roadRCodes)
|
||||
{
|
||||
_gl = gl;
|
||||
GlTexture = glTexture;
|
||||
|
|
@ -68,6 +69,9 @@ public sealed unsafe class TerrainAtlas : IDisposable
|
|||
CornerAlphaLayers = cornerLayers;
|
||||
SideAlphaLayers = sideLayers;
|
||||
RoadAlphaLayers = roadLayers;
|
||||
CornerAlphaTCodes = cornerTCodes;
|
||||
SideAlphaTCodes = sideTCodes;
|
||||
RoadAlphaRCodes = roadRCodes;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -159,14 +163,14 @@ public sealed unsafe class TerrainAtlas : IDisposable
|
|||
// ---- Alpha atlas (new in Phase 3c.2) ----
|
||||
// texMerge is guaranteed non-null here: the early return above exited
|
||||
// if texMerge?.TerrainDesc was null.
|
||||
var (glAlpha, alphaLayerCount, cornerLayers, sideLayers, roadLayers) =
|
||||
BuildAlphaAtlas(gl, dats, texMerge!);
|
||||
var alphaBuild = BuildAlphaAtlas(gl, dats, texMerge!);
|
||||
|
||||
return new TerrainAtlas(
|
||||
gl,
|
||||
tex, map, layerCount,
|
||||
glAlpha, alphaLayerCount,
|
||||
cornerLayers, sideLayers, roadLayers);
|
||||
alphaBuild.gl, alphaBuild.layerCount,
|
||||
alphaBuild.corner, alphaBuild.side, alphaBuild.road,
|
||||
alphaBuild.cornerTCodes, alphaBuild.sideTCodes, alphaBuild.roadRCodes);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -180,20 +184,28 @@ public sealed unsafe class TerrainAtlas : IDisposable
|
|||
/// <c>TerrainBlending.BuildSurface</c> which layer to cite for each
|
||||
/// corner/side/road alpha source.
|
||||
/// </summary>
|
||||
private static (uint gl, int layerCount,
|
||||
IReadOnlyList<byte> corner, IReadOnlyList<byte> side, IReadOnlyList<byte> road)
|
||||
BuildAlphaAtlas(GL gl, DatCollection dats, DatReaderWriter.Types.TexMerge texMerge)
|
||||
private readonly record struct AlphaAtlasBuildResult(
|
||||
uint gl, int layerCount,
|
||||
IReadOnlyList<byte> corner, IReadOnlyList<byte> side, IReadOnlyList<byte> road,
|
||||
IReadOnlyList<uint> cornerTCodes, IReadOnlyList<uint> sideTCodes, IReadOnlyList<uint> roadRCodes);
|
||||
|
||||
private static AlphaAtlasBuildResult BuildAlphaAtlas(
|
||||
GL gl, DatCollection dats, DatReaderWriter.Types.TexMerge texMerge)
|
||||
{
|
||||
var decoded = new List<DecodedTexture>();
|
||||
var cornerLayers = new List<byte>();
|
||||
var sideLayers = new List<byte>();
|
||||
var roadLayers = new List<byte>();
|
||||
var cornerTCodes = new List<uint>();
|
||||
var sideTCodes = new List<uint>();
|
||||
var roadRCodes = new List<uint>();
|
||||
|
||||
foreach (var entry in texMerge.CornerTerrainMaps)
|
||||
{
|
||||
if (TryDecodeAlphaMap(dats, (uint)entry.TextureId, out var dtex))
|
||||
{
|
||||
cornerLayers.Add((byte)decoded.Count);
|
||||
cornerTCodes.Add(entry.TCode);
|
||||
decoded.Add(dtex);
|
||||
}
|
||||
else
|
||||
|
|
@ -206,6 +218,7 @@ public sealed unsafe class TerrainAtlas : IDisposable
|
|||
if (TryDecodeAlphaMap(dats, (uint)entry.TextureId, out var dtex))
|
||||
{
|
||||
sideLayers.Add((byte)decoded.Count);
|
||||
sideTCodes.Add(entry.TCode);
|
||||
decoded.Add(dtex);
|
||||
}
|
||||
else
|
||||
|
|
@ -218,6 +231,7 @@ public sealed unsafe class TerrainAtlas : IDisposable
|
|||
if (TryDecodeAlphaMap(dats, (uint)entry.TextureId, out var dtex))
|
||||
{
|
||||
roadLayers.Add((byte)decoded.Count);
|
||||
roadRCodes.Add(entry.RCode);
|
||||
decoded.Add(dtex);
|
||||
}
|
||||
else
|
||||
|
|
@ -238,7 +252,10 @@ public sealed unsafe class TerrainAtlas : IDisposable
|
|||
gl.TexSubImage3D(TextureTarget.Texture2DArray, 0, 0, 0, 0, 1, 1, 1,
|
||||
GLPixelFormat.Rgba, PixelType.UnsignedByte, p);
|
||||
gl.BindTexture(TextureTarget.Texture2DArray, 0);
|
||||
return (fallbackAlpha, 1, cornerLayers, sideLayers, roadLayers);
|
||||
return new AlphaAtlasBuildResult(
|
||||
fallbackAlpha, 1,
|
||||
cornerLayers, sideLayers, roadLayers,
|
||||
cornerTCodes, sideTCodes, roadRCodes);
|
||||
}
|
||||
|
||||
// Alpha maps should all be uniform size (WorldBuilder asserts 512×512).
|
||||
|
|
@ -280,7 +297,10 @@ public sealed unsafe class TerrainAtlas : IDisposable
|
|||
$"AlphaAtlas: {decoded.Count} layers at {aMaxW}x{aMaxH} "
|
||||
+ $"(corners={cornerLayers.Count}, sides={sideLayers.Count}, roads={roadLayers.Count})");
|
||||
|
||||
return (glAlpha, decoded.Count, cornerLayers, sideLayers, roadLayers);
|
||||
return new AlphaAtlasBuildResult(
|
||||
glAlpha, decoded.Count,
|
||||
cornerLayers, sideLayers, roadLayers,
|
||||
cornerTCodes, sideTCodes, roadRCodes);
|
||||
}
|
||||
|
||||
private static bool TryDecodeAlphaMap(DatCollection dats, uint surfaceTextureId, out DecodedTexture decoded)
|
||||
|
|
@ -354,7 +374,8 @@ public sealed unsafe class TerrainAtlas : IDisposable
|
|||
gl,
|
||||
tex, new Dictionary<uint, uint> { [0] = 0u }, 1,
|
||||
alphaTex, 1,
|
||||
Array.Empty<byte>(), Array.Empty<byte>(), Array.Empty<byte>());
|
||||
Array.Empty<byte>(), Array.Empty<byte>(), Array.Empty<byte>(),
|
||||
Array.Empty<uint>(), Array.Empty<uint>(), Array.Empty<uint>());
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
|
|
|
|||
|
|
@ -4,6 +4,19 @@ using Silk.NET.OpenGL;
|
|||
|
||||
namespace AcDream.App.Rendering;
|
||||
|
||||
/// <summary>
|
||||
/// Draws the Phase 3c per-cell terrain mesh. Each landblock owns its own
|
||||
/// VBO+EBO+VAO (no chunking yet — deferred to a hypothetical Phase 3d) and
|
||||
/// gets drawn with a single DrawElements call per landblock.
|
||||
///
|
||||
/// Attribute layout (see TerrainVertex for the byte layout):
|
||||
/// location 0: vec3 aPos (3 floats)
|
||||
/// location 1: vec3 aNormal (3 floats)
|
||||
/// location 2: uvec4 aPacked0 (4 bytes, Data0)
|
||||
/// location 3: uvec4 aPacked1 (4 bytes, Data1)
|
||||
/// location 4: uvec4 aPacked2 (4 bytes, Data2)
|
||||
/// location 5: uvec4 aPacked3 (4 bytes, Data3)
|
||||
/// </summary>
|
||||
public sealed unsafe class TerrainRenderer : IDisposable
|
||||
{
|
||||
private readonly GL _gl;
|
||||
|
|
@ -33,7 +46,7 @@ public sealed unsafe class TerrainRenderer : IDisposable
|
|||
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, gpu.Vbo);
|
||||
fixed (void* p = meshData.Vertices)
|
||||
_gl.BufferData(BufferTargetARB.ArrayBuffer,
|
||||
(nuint)(meshData.Vertices.Length * sizeof(Vertex)), p, BufferUsageARB.StaticDraw);
|
||||
(nuint)(meshData.Vertices.Length * sizeof(TerrainVertex)), p, BufferUsageARB.StaticDraw);
|
||||
|
||||
gpu.Ebo = _gl.GenBuffer();
|
||||
_gl.BindBuffer(BufferTargetARB.ElementArrayBuffer, gpu.Ebo);
|
||||
|
|
@ -41,15 +54,26 @@ public sealed unsafe class TerrainRenderer : IDisposable
|
|||
_gl.BufferData(BufferTargetARB.ElementArrayBuffer,
|
||||
(nuint)(meshData.Indices.Length * sizeof(uint)), p, BufferUsageARB.StaticDraw);
|
||||
|
||||
uint stride = (uint)sizeof(Vertex);
|
||||
uint stride = (uint)sizeof(TerrainVertex);
|
||||
|
||||
// location 0: Position (12 bytes)
|
||||
_gl.EnableVertexAttribArray(0);
|
||||
_gl.VertexAttribPointer(0, 3, VertexAttribPointerType.Float, false, stride, (void*)0);
|
||||
// location 1: Normal (12 bytes, offset 12)
|
||||
_gl.EnableVertexAttribArray(1);
|
||||
_gl.VertexAttribPointer(1, 3, VertexAttribPointerType.Float, false, stride, (void*)(3 * sizeof(float)));
|
||||
|
||||
// location 2..5: Data0..Data3 as uvec4 byte attributes (4 bytes each,
|
||||
// offsets 24, 28, 32, 36). The shader reads .x/.y/.z/.w as 8-bit fields.
|
||||
nint dataOffset = 6 * sizeof(float); // 24 bytes
|
||||
_gl.EnableVertexAttribArray(2);
|
||||
_gl.VertexAttribPointer(2, 2, VertexAttribPointerType.Float, false, stride, (void*)(6 * sizeof(float)));
|
||||
_gl.VertexAttribIPointer(2, 4, VertexAttribIType.UnsignedByte, stride, (void*)dataOffset);
|
||||
_gl.EnableVertexAttribArray(3);
|
||||
_gl.VertexAttribIPointer(3, 1, VertexAttribIType.UnsignedInt, stride, (void*)(8 * sizeof(float)));
|
||||
_gl.VertexAttribIPointer(3, 4, VertexAttribIType.UnsignedByte, stride, (void*)(dataOffset + 4));
|
||||
_gl.EnableVertexAttribArray(4);
|
||||
_gl.VertexAttribIPointer(4, 4, VertexAttribIType.UnsignedByte, stride, (void*)(dataOffset + 8));
|
||||
_gl.EnableVertexAttribArray(5);
|
||||
_gl.VertexAttribIPointer(5, 4, VertexAttribIType.UnsignedByte, stride, (void*)(dataOffset + 12));
|
||||
|
||||
_gl.BindVertexArray(0);
|
||||
_landblocks.Add(gpu);
|
||||
|
|
@ -61,8 +85,16 @@ public sealed unsafe class TerrainRenderer : IDisposable
|
|||
_shader.SetMatrix4("uView", camera.View);
|
||||
_shader.SetMatrix4("uProjection", camera.Projection);
|
||||
|
||||
// Bind terrain atlas on unit 0 and alpha atlas on unit 1.
|
||||
_gl.ActiveTexture(TextureUnit.Texture0);
|
||||
_gl.BindTexture(TextureTarget.Texture2DArray, _atlas.GlTexture);
|
||||
_gl.ActiveTexture(TextureUnit.Texture1);
|
||||
_gl.BindTexture(TextureTarget.Texture2DArray, _atlas.GlAlphaTexture);
|
||||
|
||||
int terrainLoc = _gl.GetUniformLocation(_shader.Program, "uTerrain");
|
||||
if (terrainLoc >= 0) _gl.Uniform1(terrainLoc, 0);
|
||||
int alphaLoc = _gl.GetUniformLocation(_shader.Program, "uAlpha");
|
||||
if (alphaLoc >= 0) _gl.Uniform1(alphaLoc, 1);
|
||||
|
||||
foreach (var lb in _landblocks)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -3,87 +3,179 @@ using DatReaderWriter.DBObjs;
|
|||
|
||||
namespace AcDream.Core.Terrain;
|
||||
|
||||
public sealed record LandblockMeshData(Vertex[] Vertices, uint[] Indices);
|
||||
/// <summary>
|
||||
/// Terrain mesh data for a single landblock: 64 cells × 6 vertices per cell =
|
||||
/// 384 vertices. All 6 vertices of a cell share the same <c>Data0..Data3</c>
|
||||
/// blend recipe; the vertex shader derives UVs and corner index from
|
||||
/// <c>gl_VertexID % 6</c> plus the split-direction bit.
|
||||
/// </summary>
|
||||
public sealed record LandblockMeshData(TerrainVertex[] Vertices, uint[] Indices);
|
||||
|
||||
public static class LandblockMesh
|
||||
{
|
||||
private const int VerticesPerSide = 9;
|
||||
private const int CellsPerSide = VerticesPerSide - 1;
|
||||
private const float CellSize = 24.0f;
|
||||
// Phase 2b: tile terrain textures ~4x per landblock instead of stretching
|
||||
// a single texture across the whole 192-unit patch.
|
||||
private const float TexCoordDivisor = CellsPerSide / 4.0f;
|
||||
public const int HeightmapSide = 9; // 9×9 heightmap samples
|
||||
public const int CellsPerSide = 8; // 8×8 cells per landblock
|
||||
public const int VerticesPerCell = 6; // two triangles
|
||||
public const int VerticesPerLandblock = CellsPerSide * CellsPerSide * VerticesPerCell; // 384
|
||||
public const float CellSize = 24.0f;
|
||||
public const float LandblockSize = CellsPerSide * CellSize; // 192
|
||||
|
||||
// TerrainInfo bit layout: bits 0-1 Road, bits 2-6 Type (5-bit
|
||||
// TerrainTextureType), bits 11-15 Scenery. Road flag is the 2-bit field
|
||||
// at the LSB; AC's per-corner road value for GetPalCode takes the mask
|
||||
// as an int 0..3.
|
||||
private const int RoadMask = 0x3;
|
||||
private const int TypeShift = 2;
|
||||
private const int TypeMask = 0x1F;
|
||||
|
||||
/// <summary>
|
||||
/// Build a per-cell terrain mesh for one landblock. Each cell is looked
|
||||
/// up in the shared <paramref name="surfaceCache"/> by palette code; only
|
||||
/// palette codes not yet seen in this scene call
|
||||
/// <see cref="TerrainBlending.BuildSurface"/>.
|
||||
/// </summary>
|
||||
/// <param name="block">Landblock dat record (heightmap + terrain info).</param>
|
||||
/// <param name="landblockX">Landblock X coord (high byte of landblock id) for split-direction hashing.</param>
|
||||
/// <param name="landblockY">Landblock Y coord (second byte of landblock id).</param>
|
||||
/// <param name="heightTable">Region.LandDefs.LandHeightTable — 256 float heights.</param>
|
||||
/// <param name="ctx">TerrainAtlas-derived blending inputs.</param>
|
||||
/// <param name="surfaceCache">Shared SurfaceInfo cache keyed by palette code.</param>
|
||||
public static LandblockMeshData Build(
|
||||
LandBlock block,
|
||||
uint landblockX,
|
||||
uint landblockY,
|
||||
float[] heightTable,
|
||||
IReadOnlyDictionary<uint, uint> terrainTypeToLayer)
|
||||
TerrainBlendingContext ctx,
|
||||
Dictionary<uint, SurfaceInfo> surfaceCache)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(block);
|
||||
ArgumentNullException.ThrowIfNull(heightTable);
|
||||
ArgumentNullException.ThrowIfNull(terrainTypeToLayer);
|
||||
ArgumentNullException.ThrowIfNull(ctx);
|
||||
ArgumentNullException.ThrowIfNull(surfaceCache);
|
||||
if (heightTable.Length < 256)
|
||||
throw new ArgumentException("heightTable must have 256 entries", nameof(heightTable));
|
||||
|
||||
// Precompute all 81 heights in (x,y) grid order so we can do cheap
|
||||
// neighbor lookups when computing per-vertex normals by central differences.
|
||||
// Heights are packed x-major (Height[x*9+y]) matching the fix in cc55c3f.
|
||||
var heights = new float[VerticesPerSide, VerticesPerSide];
|
||||
for (int x = 0; x < VerticesPerSide; x++)
|
||||
for (int y = 0; y < VerticesPerSide; y++)
|
||||
heights[x, y] = heightTable[block.Height[x * VerticesPerSide + y]];
|
||||
// Pre-sample all 81 heights into a 2D array (x-major indexing). This
|
||||
// doubles as the source for per-vertex normals via central differences
|
||||
// (Phase 3b lighting, preserved through the per-cell refactor).
|
||||
var heights = new float[HeightmapSide, HeightmapSide];
|
||||
for (int x = 0; x < HeightmapSide; x++)
|
||||
for (int y = 0; y < HeightmapSide; y++)
|
||||
heights[x, y] = heightTable[block.Height[x * HeightmapSide + y]];
|
||||
|
||||
var vertices = new Vertex[VerticesPerSide * VerticesPerSide];
|
||||
for (int y = 0; y < VerticesPerSide; y++)
|
||||
// Pre-compute all 81 vertex normals so the inner cell loop is a pure
|
||||
// lookup. Central differences on the heightmap → smooth normal field.
|
||||
var normals = new Vector3[HeightmapSide, HeightmapSide];
|
||||
for (int x = 0; x < HeightmapSide; x++)
|
||||
for (int y = 0; y < HeightmapSide; y++)
|
||||
{
|
||||
for (int x = 0; x < VerticesPerSide; x++)
|
||||
int xL = Math.Max(x - 1, 0);
|
||||
int xR = Math.Min(x + 1, HeightmapSide - 1);
|
||||
int yD = Math.Max(y - 1, 0);
|
||||
int yU = Math.Min(y + 1, HeightmapSide - 1);
|
||||
float dx = (heights[xR, y] - heights[xL, y]) / ((xR - xL) * CellSize);
|
||||
float dy = (heights[x, yU] - heights[x, yD]) / ((yU - yD) * CellSize);
|
||||
normals[x, y] = Vector3.Normalize(new Vector3(-dx, -dy, 1f));
|
||||
}
|
||||
|
||||
var vertices = new TerrainVertex[VerticesPerLandblock];
|
||||
var indices = new uint[VerticesPerLandblock]; // 1 index per vertex (no deduplication)
|
||||
|
||||
int vi = 0;
|
||||
for (int cy = 0; cy < CellsPerSide; cy++)
|
||||
{
|
||||
for (int cx = 0; cx < CellsPerSide; cx++)
|
||||
{
|
||||
int vi = y * VerticesPerSide + x;
|
||||
int hi = x * VerticesPerSide + y;
|
||||
float height = heights[x, y];
|
||||
// Four corner TerrainInfo samples (x-major block.Terrain[x*9+y]).
|
||||
var tBL = block.Terrain[cx * HeightmapSide + cy];
|
||||
var tBR = block.Terrain[(cx + 1) * HeightmapSide + cy];
|
||||
var tTR = block.Terrain[(cx + 1) * HeightmapSide + (cy + 1)];
|
||||
var tTL = block.Terrain[cx * HeightmapSide + (cy + 1)];
|
||||
|
||||
// TerrainInfo is bit-packed: bits 0-1 Road, bits 2-6 Type (5-bit
|
||||
// TerrainTextureType enum), bits 11-15 Scenery. The atlas keys on
|
||||
// Type only, matching Region.TerrainInfo.LandSurfaces.TexMerge.TerrainDesc
|
||||
// which lists SurfaceTexture ids per TerrainTextureType.
|
||||
uint terrainType = (uint)block.Terrain[hi].Type;
|
||||
if (!terrainTypeToLayer.TryGetValue(terrainType, out var layer))
|
||||
layer = 0;
|
||||
int rBL = tBL.Road & RoadMask;
|
||||
int rBR = tBR.Road & RoadMask;
|
||||
int rTR = tTR.Road & RoadMask;
|
||||
int rTL = tTL.Road & RoadMask;
|
||||
int ttBL = (int)tBL.Type & TypeMask;
|
||||
int ttBR = (int)tBR.Type & TypeMask;
|
||||
int ttTR = (int)tTR.Type & TypeMask;
|
||||
int ttTL = (int)tTL.Type & TypeMask;
|
||||
|
||||
// Per-vertex normal from central differences on the heightmap.
|
||||
// Surface is z=h(x,y); tangents Sx=(1,0,dh/dx), Sy=(0,1,dh/dy);
|
||||
// normal = Sx x Sy = (-dh/dx, -dh/dy, 1), normalized.
|
||||
// Edge vertices use forward/backward difference instead of central.
|
||||
int xL = Math.Max(x - 1, 0);
|
||||
int xR = Math.Min(x + 1, VerticesPerSide - 1);
|
||||
int yD = Math.Max(y - 1, 0);
|
||||
int yU = Math.Min(y + 1, VerticesPerSide - 1);
|
||||
float dx = (heights[xR, y] - heights[xL, y]) / ((xR - xL) * CellSize);
|
||||
float dy = (heights[x, yU] - heights[x, yD]) / ((yU - yD) * CellSize);
|
||||
var normal = Vector3.Normalize(new Vector3(-dx, -dy, 1f));
|
||||
// WorldBuilder's palCode convention: t1=BL, t2=BR, t3=TR, t4=TL.
|
||||
uint palCode = TerrainBlending.GetPalCode(
|
||||
rBL, rBR, rTR, rTL, ttBL, ttBR, ttTR, ttTL);
|
||||
|
||||
vertices[vi] = new Vertex(
|
||||
Position: new Vector3(x * CellSize, y * CellSize, height),
|
||||
Normal: normal,
|
||||
TexCoord: new Vector2(x / TexCoordDivisor, y / TexCoordDivisor),
|
||||
TerrainLayer: layer);
|
||||
if (!surfaceCache.TryGetValue(palCode, out var surf))
|
||||
{
|
||||
surf = TerrainBlending.BuildSurface(palCode, ctx);
|
||||
surfaceCache[palCode] = surf;
|
||||
}
|
||||
|
||||
var split = TerrainBlending.CalculateSplitDirection(
|
||||
landblockX, (uint)cx, landblockY, (uint)cy);
|
||||
|
||||
var (d0, d1, d2, d3) = TerrainBlending.FillCellData(surf, split);
|
||||
|
||||
// Corner positions in landblock-local space.
|
||||
var posBL = new Vector3( cx * CellSize, cy * CellSize, heights[cx, cy ]);
|
||||
var posBR = new Vector3((cx + 1) * CellSize, cy * CellSize, heights[cx + 1, cy ]);
|
||||
var posTR = new Vector3((cx + 1) * CellSize, (cy + 1) * CellSize, heights[cx + 1, cy + 1]);
|
||||
var posTL = new Vector3( cx * CellSize, (cy + 1) * CellSize, heights[cx, cy + 1]);
|
||||
|
||||
var nBL = normals[cx, cy];
|
||||
var nBR = normals[cx + 1, cy];
|
||||
var nTR = normals[cx + 1, cy + 1];
|
||||
var nTL = normals[cx, cy + 1];
|
||||
|
||||
// Emit 6 vertices in the exact order WorldBuilder's Landscape.vert
|
||||
// expects. The vertex shader maps gl_VertexID % 6 → corner index
|
||||
// for UV lookup, so the CPU order must match.
|
||||
//
|
||||
// SWtoNE (splitDir=0):
|
||||
// vIdx: 0 1 2 3 4 5
|
||||
// corner: 0 3 1 1 3 2
|
||||
// BL TL BR BR TL TR
|
||||
// SEtoNW (splitDir=1):
|
||||
// vIdx: 0 1 2 3 4 5
|
||||
// corner: 0 2 1 0 3 2
|
||||
// BL TR BR BL TL TR
|
||||
if (split == CellSplitDirection.SWtoNE)
|
||||
{
|
||||
WriteCell(vertices, ref vi, d0, d1, d2, d3,
|
||||
posBL, nBL, posTL, nTL, posBR, nBR,
|
||||
posBR, nBR, posTL, nTL, posTR, nTR);
|
||||
}
|
||||
else
|
||||
{
|
||||
WriteCell(vertices, ref vi, d0, d1, d2, d3,
|
||||
posBL, nBL, posTR, nTR, posBR, nBR,
|
||||
posBL, nBL, posTL, nTL, posTR, nTR);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var indices = new uint[CellsPerSide * CellsPerSide * 6];
|
||||
int idx = 0;
|
||||
for (int y = 0; y < CellsPerSide; y++)
|
||||
{
|
||||
for (int x = 0; x < CellsPerSide; x++)
|
||||
{
|
||||
uint a = (uint)(y * VerticesPerSide + x);
|
||||
uint b = (uint)(y * VerticesPerSide + x + 1);
|
||||
uint c = (uint)((y + 1) * VerticesPerSide + x);
|
||||
uint d = (uint)((y + 1) * VerticesPerSide + x + 1);
|
||||
indices[idx++] = a; indices[idx++] = b; indices[idx++] = d;
|
||||
indices[idx++] = a; indices[idx++] = d; indices[idx++] = c;
|
||||
}
|
||||
}
|
||||
// Indices are trivial 0..383 since we don't deduplicate verts.
|
||||
for (uint i = 0; i < VerticesPerLandblock; i++)
|
||||
indices[i] = i;
|
||||
|
||||
return new LandblockMeshData(vertices, indices);
|
||||
}
|
||||
|
||||
private static void WriteCell(
|
||||
TerrainVertex[] verts, ref int vi,
|
||||
uint d0, uint d1, uint d2, uint d3,
|
||||
Vector3 p0, Vector3 n0,
|
||||
Vector3 p1, Vector3 n1,
|
||||
Vector3 p2, Vector3 n2,
|
||||
Vector3 p3, Vector3 n3,
|
||||
Vector3 p4, Vector3 n4,
|
||||
Vector3 p5, Vector3 n5)
|
||||
{
|
||||
verts[vi++] = new TerrainVertex(p0, n0, d0, d1, d2, d3);
|
||||
verts[vi++] = new TerrainVertex(p1, n1, d0, d1, d2, d3);
|
||||
verts[vi++] = new TerrainVertex(p2, n2, d0, d1, d2, d3);
|
||||
verts[vi++] = new TerrainVertex(p3, n3, d0, d1, d2, d3);
|
||||
verts[vi++] = new TerrainVertex(p4, n4, d0, d1, d2, d3);
|
||||
verts[vi++] = new TerrainVertex(p5, n5, d0, d1, d2, d3);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
28
src/AcDream.Core/Terrain/TerrainVertex.cs
Normal file
28
src/AcDream.Core/Terrain/TerrainVertex.cs
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
using System.Numerics;
|
||||
|
||||
namespace AcDream.Core.Terrain;
|
||||
|
||||
/// <summary>
|
||||
/// Per-vertex terrain geometry + blend data. Terrain uses a cell-based
|
||||
/// layout: 64 cells per landblock, 6 vertices per cell (two triangles),
|
||||
/// 384 vertices per landblock total. All 6 vertices in a cell carry
|
||||
/// identical <c>Data0..3</c> (the cell's blend recipe from
|
||||
/// <see cref="TerrainBlending.FillCellData"/>); the vertex shader derives
|
||||
/// which of the 4 cell corners a given vertex represents from
|
||||
/// <c>gl_VertexID % 6</c> plus the split direction bit.
|
||||
///
|
||||
/// Normal is stored per vertex via Phase 3b's central-difference scheme on
|
||||
/// the 9×9 heightmap — this lets the fragment shader interpolate a smooth
|
||||
/// normal across triangles (softer than WorldBuilder's <c>dFdx</c>/<c>dFdy</c>
|
||||
/// flat-shaded approach). UVs are derived from the corner index in the
|
||||
/// vertex shader — not stored here.
|
||||
///
|
||||
/// Size: 12 (position) + 12 (normal) + 4*4 (Data0..3) = 40 bytes.
|
||||
/// </summary>
|
||||
public readonly record struct TerrainVertex(
|
||||
Vector3 Position,
|
||||
Vector3 Normal,
|
||||
uint Data0,
|
||||
uint Data1,
|
||||
uint Data2,
|
||||
uint Data3);
|
||||
Loading…
Add table
Add a link
Reference in a new issue