Measured, not theorised. Records for the next session:
- #226 detail overlay is NOT implemented at 255b0aae, and the
BuildingDetailTextures Options checkbox is wired to NOTHING (settings +
UI exist; no consumer in Rendering/). The port must consume that
existing setting.
- Retail authors detail per terrain entry; Dereth has 33 entries but only
3 distinct detail textures, one 64x64 backing 88% of them.
- The detail pass is a framebuffer blend, not DOT3: environment uses
DSTCOLOR+INVSRCALPHA under BLENDOP_ADD, landscape uses SRCALPHA. Decoded
texture means give factors 1.03-1.20, so retail's own pass BRIGHTENS
rather than roughens - a real design decision for the port.
- EnvironmentDetailTextures gates buildings+interiors, NOT landscape
(ChangeRegion passes landscape=0).
- Refuted and recorded so nobody re-chases: highres is NOT an override dat
(20,684/2,294 ids, zero overlap - we already use it); the detail
textures are NOT normal maps (24%/8%/15% unit-length); but the engine
DOES have BumpMap/DotProduct3 machinery.
- Terrain normals are flat per-face with no averaging - whether retail
smooths is the open parity question.
- Atmospheric rendering want captured: opt-in shader packs, tier table,
and the shadow constraints (alpha-tested casters, animated casters via
the N.5 SSBO, indoor gating, streaming-bounded cascades, the #129 bias
trap, and the shadow_objects naming trap).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
15 KiB
Terrain detail, terrain normals, and atmospheric rendering — verified findings
Date: 2026-08-21 · Status: RESEARCH / HANDOFF — no code written
Tree: main @ 255b0aae (branch claude/git-sync-status-5fb1d2, level with main)
Everything below was measured against the installed DATs, the current source tree, or the named-retail decomp. It exists so the next session does not re-derive it. Where a claim was tested and refuted, that is recorded too — those are the expensive ones to rediscover.
1. The detail-texture overlay is NOT implemented (and the setting lies)
Renderer: terrain_modern.frag / .vert contain zero detail
references — no second sampler, no detail UV, no distance-fade term. Tree-wide
there is no DetailTex / detail-surface concept in
src/AcDream.App/Rendering/. Verified at 255b0aae, not only at the older
bb1640f7.
But the SETTING exists and is DEAD:
| Piece | Location |
|---|---|
bool BuildingDetailTextures = true |
AcDream.UI.Abstractions/Panels/Settings/DisplaySettings.cs:77 |
persisted as "buildingDetailTextures" |
SettingsStore.cs:99 (read), :677 (write) |
Options checkbox, retail string ID_Graphics_BuildingDetailTextures |
ConfigOptionsPageController.cs:772-774 |
consumers in Rendering/ |
none |
The Options window shows "Building Detail Textures", checked by default, persisting across sessions — wired to nothing. This is worse than not having the setting: the UI implies a feature that does not exist. #226's port must consume this existing setting, not invent a new one, and its acceptance test must include "toggling it visibly changes the scene".
Already shipped and NOT to be confused with this: #155 base tiling
(bb5acab9, ported TexMerge::CopyAndTile/Merge) — that fixed the
stretched look. #155 and #226 were conflated once already.
2. How retail's detail pass actually works
Authored data (measured from the installed DATs)
Detail textures are authored per terrain entry:
TerrainTex.DetailTextureId + TerrainTex.DetailTexTiling, reached via
LandSurf::GetDetailTex / GetDetailTiling.
Region 0x13000000 "Dereth": 33 terrain entries, only 3 distinct detail
textures.
| Detail texture | RenderSurface | Size | Used by |
|---|---|---|---|
0x050012AF |
0x060037D2 |
64x64 A8R8G8B8 | 29 of 33 (88%) |
0x05001786 |
0x06006D57 |
256x256 A8R8G8B8 | 2 — BarrenRock, LushGrass |
0x05001787 |
0x06006D58 |
256x256 A8R8G8B8 | 2 — Grassland, Ice |
DetailTexTiling is mostly 1 (4 for grass/rock types, 8 for
FauxWaterRunning, 2 for RoadType) against a base TexTiling of 2.
This confirms the community observation that the client "uses the same noise texture for everything" — one 64x64 texture backs 88% of Dereth's terrain.
Four categories, and which the setting actually gates
LScape::SetDetailTexturing (0x00506b40) manages four independent detail
surfaces + tilings: 0 = landscape, 1 = building, 2 = environment,
3 = object. Also GenerateDetailSurface (0x00506230),
CleanupDetailSurfaces (0x00504ae0).
LScape::ChangeRegion (0x00506cb0) calls:
SetDetailTexturing(this, 0, EnvDetail, EnvDetail, 0);
// ^landscape=OFF ^building ^environment ^object=OFF
gated on the single Render::m_RenderPrefs.EnvironmentDetailTextures. So
retail's setting is a BUILDINGS-AND-INTERIORS setting, not a terrain
setting — which explains why its UI label is "Building Detail Textures".
SmartBox::SetDetailTexturing (0x00451df0) can pass landscape through — its
callers are untraced; that is the open question for whether terrain detail
is ever enabled in practice.
The blend — and retail's own bug
ACRender::SetDetailSurfaceInternal (0x006b6280):
SetStageTexture(stage, detailTex);
SetSamplerAddressMode(stage, TEXADDRESS_WRAP, TEXADDRESS_WRAP);
SetSamplerFilterMode(stage, TEXFILTER_LINEAR x3);
if (stage == 0) {
SetBlendFunction(curr_detail_src_blend, curr_detail_dst_blend, BLENDOP_ADD);
SetAlphaBlendEnable(1);
SetDepthBufferMode(DEPTHTEST_LESSEQUAL, 1);
}
It is a framebuffer blend, not a DOT3/normal-map path. Factors differ per category:
| Path | site | src | dst |
|---|---|---|---|
| environment / interiors | 0059f1c2 |
9 = BLEND_DSTCOLOR |
6 = BLEND_INVSRCALPHA |
| landscape | 005a19a1 |
5 = BLEND_SRCALPHA |
6 = BLEND_INVSRCALPHA |
static default (.data) |
0081eca0 |
5 | 6 |
DSTCOLOR + INVSRCALPHA under BLENDOP_ADD evaluates to
dest x (detailLum + 1 - alpha). Decoded means of the three real textures:
| texture | lum | alpha | factor | effect |
|---|---|---|---|---|
0x060037D2 |
0.459 | 0.282 | 1.177 | +18% brighter |
0x06006D57 |
0.414 | 0.210 | 1.204 | +20% brighter |
0x06006D58 |
0.165 | 0.132 | 1.033 | +3% brighter |
Retail's own detail pass BRIGHTENS rather than roughens. A proper
roughening modulate needs dst = ZERO so the result centres on 1.0; here the
dest x (1-alpha) term adds most of the original back on top. This
independently confirms the community observation that it looks like surfaces
"reflect more instead of being rougher", and it raises a real design decision
for #226 (see open questions).
Terrain draw path
ACRender::landPolyDraw — two overloads, 0x006b6320 (single-poly) and
0x006b6760 (two-poly). Detail is gated on
trysinglepass && m_caps.bCanDoSinglePassDetailing && curr_detail_surface != 0,
then SetDetailSurfaceInternal(1). Single-pass multitexture, with a
non-single-pass fallback that is untraced. Vertex lighting comes from
ACRender::curLandBlockVertexLighting.
3. REFUTED claims — do not re-chase these
- "client_highres.dat is an override dat and we pick the low-res copy."
FALSE. Measured twice, including at
255b0aae: portal 20,684 RenderSurfaces, highres 2,294, overlap 0 — fully disjoint IDs.TextureCache(Portal.TryGet || HighRes.TryGet) andDatCollectionAdapter.TryResolvePreferred(Portal then HighRes) both reach them, so every high-res surface resolves. We already use them. The "Portal precedence is load-bearing" comment inDatCollectionAdapteris vestigial given zero overlap. QUESTION CLOSED. - "The detail textures are normal maps." FALSE. The 64x64 one is blue-dominant (RGB 0.303/0.469/0.824), which looks like a tangent-space normal map, but unpacking RGB as a vector and measuring length gives 24.3% / 7.7% / 14.9% unit-length pixels (a real normal map is ~100%). They are colour+alpha detail textures.
- "AC has no normal-map capability at all." ALSO FALSE — the engine has a
BumpMapparameter, theDotProduct3texture op, and a hardware capability probem_caps.bTexOpDotProduct3(read at0x0059f5c7). The machinery exists; the detail textures simply are not normals. What uses the bump path is untraced.
4. Terrain geometry and normals
- Landblock = 192 m; 8x8 cells @ 24 m; 9x9 = 81 height samples; 2 triangles per cell => 128 triangles per landblock.
- Heights are quantised: each sample indexes a 256-entry
region.LandDefs.LandHeightTable. This caps what smoothing or subdivision can achieve — they smooth quantisation, they do not recover unsampled detail. - Diagonal split direction comes from the
FSplitNESWhash of world cell coords (constants0x0CCAC033,0x421BE3BD,0x6C1AC587,0x519B8F25). src/AcDream.Core/Rendering/Wb/TerrainUtils.csGetNormalreturns the flat per-face normal (cross product of whichever triangle the sample lands in). No averaging anywhere in the tree. That is what produces the faceted look.
Open question, must be answered from the decomp, no guessing: does retail
smooth terrain vertex normals? Entry points: ACRender::landPolyDraw (both
overloads, note ACRender::curLandBlockVertexLighting),
LScape::calc_object_light (0x00455730), the LScape sunlight/ambient block
~0x00455b51, and how a terrain CPolygon's vertex normals/colours are built.
TerrainUtils is WorldBuilder-derived, so WB may have simplified — check
retail, not WB.
- retail smooths => parity gap, schedule like #226.
- retail is faceted => opt-in enhancement, belongs with the shader-pack work.
Smoothing costs zero geometry change and therefore zero physics risk: collision, walkability, slope tests and the 4M-cell conformance sweep all see identical triangles.
Subdivision is NOT recommended and the next session should try to refute rather than confirm that: perceived softness is texture-frequency (§1-2) plus flat shading (this section), not silhouette resolution; the 256-step quantisation caps the gain; and it is the only one of the three that touches the physics contract — those triangles ARE the collision surface (FloorZ / ValidateWalkable, walkable-polygon tracking, precipice/cliff slide, the 4M-cell conformance sweep, and the triangle-boundary Z bug that cost five failed fix attempts). If ever wanted, the only defensible forms are coplanar surface-preserving subdivision (identical surface, better Gouraud gradients) or an interpolating spline through the original 81 samples, render-only, with a registered bounded divergence.
5. Renderer state relevant to atmospheric work
- 9 shader pairs (
debug_line,mesh_modern,particle,particle_mesh,portal_depth,sky,terrain_modern,ui_text,vk_probe) plus a compiledspv/directory. mesh_modernuses per-vertex Gouraud lighting deliberately — the A7 comment records that a per-pixel evaluation produced a hard "spotlight pool" unlike retail's fixed-function T&L. 8-lightSceneLightingUBO carryinguFogParams/uFogColor/uCameraAndTime. Two-pass alpha (opaque discard<0.95, translucent discard>=0.95and<0.05).- Sun direction already exists and already tracks the day/night cycle:
WorldRenderFrameBuilder.cs:526—-SkyStateProvider.SunDirectionFromKeyframe(keyframe), supplied asLightKind.Directional(:531,:546). The sun is a real drawn object (dayGroup.SkyObjectsviaSkyPesFrameController). - A depth-only pipeline shape already exists:
portal_depth.vert/.fragwithGpuPipelineDescription.ColorWrite = false(Gpu/GpuPipelineDescription.cs:276, honoured inGpu/Vk/VulkanGpuPipeline.cs:205) and an empty fragmentmain(). A shadow map is that pipeline aimed at the sun. activeDayGroup(weather: Clear / Cloudy / Overcast / Rainy) reaches the frame builder.- Campaign V (OpenGL -> Vulkan) closed 2026-07-29: pass-based RHI
(
IGpuDevice/IGpuFrame/IGpuPassEncoder, explicitGpuPassDescription+GpuPipelineDescription) over bindless + MDI.
Performance baseline and the binding constraint
519.7 FPS; CPU/GPU p50 1.869 / 1.096 ms (CLAUDE.md), and the dense-town
profile is CPU-SUBMISSION-BOUND (memory:
feedback_render_perf_measurement).
Consequences:
- GPU-side fullscreen work is nearly free in observed FPS — it fills GPU idle time. Tier-1 post-processing costs little.
- Anything that adds CPU submissions is expensive. Shadow cascades must not re-run visibility culling per cascade on the CPU — draw the full resident set per cascade, or move culling to a GPU compute pass.
glFinish-style profilers inflate GPU timings. Measure with the existing gates (capped/uncapped, p50/p99 CPU+GPU, pinned dense Arwic).
6. Wanted work — atmospheric rendering (user-stated)
Opt-in shader packs, modelled on Minecraft's Iris/OptiFine: the retail-faithful path stays the default and authoritative; enhanced rendering is a toggle, exposed through the plugin API. This framing is what resolves the parity tension — the divergence register is untouched because the faithful path still exists and is still what we test.
| Tier | Contents | Prerequisite |
|---|---|---|
| 1 | bloom, ACES filmic tonemap, colour grade, vignette | none — fullscreen, scales with pixels |
| 1 | screen-space sun rays (crepuscular) | sun screen-pos + occlusion mask; no shadow maps |
| 2 | cascaded directional shadows — trees, monsters, houses | second scene pass |
| 2+ | volumetric light shafts | reuses the shadow map — nearly free after shadows |
| later | SSAO, water reflections | depth + normals |
| out | true PBR | AC has no per-texture normal/roughness maps |
Dynamic shadows are an explicit user want — trees, monsters and buildings casting real sun shadows — not merely a design exercise.
Design points the user asked for:
- Ray/shaft intensity driven by AC's own authored weather
(
activeDayGroup) rather than invented constants — strong shafts at clear dawn, muted under Overcast. Combined with the already-authored moving sun this yields rays that rake low at dawn, vanish at noon, return at dusk. Retail never had this. - Composite sun rays before tonemapping so bloom picks them up and they roll off the filmic curve instead of clipping.
Shadow-specific constraints
- Alpha-tested casters. Foliage is cutout geometry (two-pass alpha), so
the shadow pass cannot use
portal_depth's empty fragment shader — it must sample and discard, or every tree casts a solid rectangle. - Animated casters. Monsters need the same per-part transforms as the main pass; those already live in the N.5 SSBO, so the shadow vertex shader can read the same buffer.
- Indoors has no sun. Gate to outdoor cells; dungeon cells use authored per-cell ambient and must not be fought.
- Cascades must be camera-relative, bounded by the two-tier streaming
window (memory:
reference_two_tier_streaming), not a fixed world extent. - Depth bias will hit issue #129's bug class — an NDC-space bias constant
spans
~ b*d^2/nearmetres of eye depth at distance; #129 leaked door-shaped holes through hills. Memory:feedback_ndc_constants_eye_space_meaning. - NAMING TRAP: in AC's codebase "shadow" means the per-cell physics
registration list (
CPhysicsObj::add_shadows_to_cells,shadow_objects) — nothing to do with lighting. Grepping "shadow" drowns in collision hits. - Quality scaling for weak hardware: half/quarter-res bloom and rays, fewer cascades, lower shadow resolution. 4K pays ~4x the tier-1 pixel cost.
7. Open questions carried forward
- Does retail smooth terrain vertex normals? (§4) — decides parity-gap vs enhancement.
- Who calls
SmartBox::SetDetailTexturing, and is landscape detail ever enabled in practice? (§2) - What is the non-single-pass detail fallback in
landPolyDraw? (§2) - What uses the
BumpMap/DotProduct3path, if not the detail textures? (§3) - For #226: port retail's brightening blend verbatim, fix it to
dst=ZEROso it roughens as intended and register the deviation, or expose both behind the setting? (§2)
8. Where this sits
M4 is the active milestone; the next planned implementation work is the #268 + TS-8 stat-chain package, and none of the above should displace it without the user's say-so. #226 and the terrain-normals question are parity gaps (schedulable now). The shader-pack tiers are post-M7, since rendering phases are frozen until the polish pass — but they are wanted, so the design should be captured rather than rediscovered.