// THE LEVEL SHADER BODY, shared by the surface-detail tiers (Settings "surface_detail"):
// dc_level.gdshader Normal maps (no POM code at all)
// dc_level_pom.gdshader Parallax (#define POM)
// Tiers are COMPILED VARIANTS, not a uniform: with the POM loop present but switched
// off by a uniform, plain normal mapping measured +0.03..+0.22 ms (scenes/test/
// _probe_spom.gd, mode pom_off_unif, 2026-10-02), so players who leave it off would
// pay for it anyway.
// Exact-brush level shader for DCLevel.
//
// DCLevel emits DCTerrain's vertex encoding deliberately (dc_level.h): COLOR.r =
// material_id/255, COLOR.a = 1 on a textured face, CUSTOM0.xy = the two per-face
// projection candidates. So the UV reconstruction below is byte-identical to the
// terrain shader's Stage-2 path in dc_terrain.cpp.
//
// What is deliberately ABSENT vs the terrain shader:
// - the crease pick. The terrain shader carries TWO projection candidates and
// chooses per fragment by comparing the pixel normal against each face's
// geometric normal, because on a voxel mesh a cell straddles two faces. On
// the EXACT mesh both candidates are the same face — dc_level.cpp sets
// proj[i] twice — so the pick is a no-op and is dropped rather than run.
// This is the material-quantization problem disappearing by construction
// (level-mesh-implementation.md §11), not being solved.
// - LOD fade / lod_color. There is no clipmap here; nothing fades.
//
// What is deliberately PRESENT:
// - textureGrad with derivatives of the CONTINUOUS uv (pre-fract). Feeding
// fract(uv) to texture() makes the GPU read the sawtooth's derivative at every
// tile seam and pick a far-too-coarse mip — that was the seam glint fixed in
// 0.746. Do not "simplify" this back to texture().
// - filter_nearest_mipmap: crisp Quake texels up close, mips keep distance clean.
uniform sampler2DArray tex_atlas : source_color, filter_nearest_mipmap, repeat_enable;
uniform sampler2D projection_table : filter_nearest;
uniform bool has_projection_table = false;
uniform int projection_rows = 0;
uniform bool use_textures = true;
// FLAT MATERIAL COLOUR. Each face is coloured only by its material id, no texture
// lookup at all -- the view for judging a room DECOMPOSITION, where every room is
// emitted with its own material name so one colour == one room. Reuses the same
// hash the no-projection debug path below uses, so the two agree.
uniform bool flat_mats = false;
// Explicit palette, indexed by material id, so the colour of a room is CHOSEN
// rather than hashed. The hash puts adjacent ids far apart in colour, which is
// wrong when the ids encode a hierarchy: a PVS region and its geometry
// sub-rooms want the same hue at different values.
uniform sampler2D room_palette : source_color, filter_nearest;
uniform bool use_palette = false;
// PBR — roughness from gloss, emissive glow, and a DarkPlaces-derived normal map.
// QuakeWadLoader.load_pbr() builds these in the SAME name order as load(), so one
// material layer indexes all three atlases and the fragment needs no second lookup.
//
// glow_atlas is RGBA: rgb = glow, a = gloss. Packed into one texture deliberately —
// both come from the same source resolution so the pack is exact, and the shader
// reads them from ONE textureGrad instead of two. Under path tracing that fetch
// happens per hit, so halving the pair is worth the RGB layer it costs.
//
// The normal atlas is GENERATED from the _bump heightmap with DarkPlaces' own math
// (wad_loader.gd:_dp_normal_from_bump), NOT hemebond's baked _norm.tga — those use
// Z=1 and give near-sideways normals (median ~88 deg). DP's bumpscale gives Z=382 and
// gentle relief. That is why DP looks subtle: the bumpscale IS the strength.
uniform sampler2DArray glow_atlas : source_color, filter_nearest_mipmap, repeat_enable;
uniform sampler2DArray norm_atlas : filter_nearest_mipmap, repeat_enable;
uniform bool use_pbr = false;
// PACK textures (#94 P1) carry ORM instead of glow+gloss: R = ambient occlusion,
// G = roughness, B = metalness, layer-for-layer with tex_atlas like the Quake pair.
// Their normals are DirectX convention, measured against each pack's displacement
// map (green ~ -dh/drow, red ~ -dh/dcol), which is exactly what sn.x*T + sn.y*B
// below wants — no flip. Linear data, so no source_color.
uniform sampler2DArray orm_atlas : filter_nearest_mipmap, repeat_enable;
uniform bool use_orm = false;
// A face with NO projection row draws this flat colour instead of the per-material
// debug hash. The pack material sets it: on a built level an untextured brush is a
// normal state (not yet painted), not a bug to make visible.
uniform bool none_plain = false;
uniform vec3 none_color : source_color = vec3(0.62, 0.6, 0.56);
uniform float normal_strength : hint_range(0.0, 2.0) = 1.0; // CANONICAL: DarkPlaces applies the normal unscaled
#ifdef POM
// PARALLAX OCCLUSION (surface detail tier 1). Height rides in orm_atlas ALPHA (white =
// the face plane, relief goes INWARD so nothing pokes out of the brush); the relief
// depth in metres is per LAYER, fitted per material by tools/fetch_texture_packs.py
// (pack.json height_m) and uploaded by LevelPackMaterial as layer_params.r.
// No discard and no DEPTH write: the material keeps Godot's shared depth/shadow
// material, so the march runs in the colour pass only (measured: discard doubles it).
uniform sampler2D layer_params : filter_nearest;
uniform float pom_max_depth_m = 0.08; // caps e.g. a 50 m aerial texture's 3.2 m relief
uniform float pom_fade_start = 8.0; // full relief nearer than this (metres)
uniform float pom_fade_end = 14.0; // plain normal mapping beyond this
uniform int pom_min_steps = 8; // head-on
uniform int pom_max_steps = 32; // grazing
#endif
// Debug instrument (default off, no effect on the shipped look): emit the texture
// LOD this pixel selects, as a colour ramp, instead of the texture. The rasterizer
// has real derivatives and its LOD is correct by construction, so a raster capture
// is ground truth; the same capture under path tracing shows what the hit shader
// actually selects. Comparing the two answers "is RAY_FOOTPRINT the right
// magnitude" directly, with no lighting, no Monte Carlo noise and no convergence to
// wait for -- which is what defeated every image-similarity approach
// (benchmarks/pt/RESULTS.md). ALBEDO goes black and the ramp is emitted, so both
// renderers show the same value.
uniform bool debug_lod = false;
// 0 = LOD ramp (0..6). 1 = VIEWPORT_SIZE/4096 in R,G -- the term most likely to be
// wrong, since the footprint is 2*t/(P11*viewport_height) and an unset height of 1.0
// puts the LOD ~10 stops too high, which is what saturation at 6 looks like.
// 2 = the same LOD on a wide 0..20 scale, so an over-large value can be READ rather
// than inferred from a clamp.
uniform int debug_lod_mode = 0;
// Sky faces EMIT. A Quake sky brush is a light source in the fiction, not a lit
// surface — the same read as a stained-glass window: the surface itself glows and
// is the brightest thing in the room. Up to 4 distinct sky material ids (START uses
// SKY1; other maps mix SKY1/SKY4), -1 = unused slot. Compared against the vertex's
// material id rather than tagged per-vertex, so this needs no mesh format change.
uniform vec4 sky_mats = vec4(-1.0, -1.0, -1.0, -1.0);
uniform float sky_emission = 2.0;
// The Quake sky, at NATIVE 256x128 — deliberately not the atlas, which resizes every
// layer to a square and destroys this layout. The texture is TWO 128x128 layers side
// by side (Quake R_InitSky): the RIGHT half is the solid starfield, the LEFT half is
// the cloud layer where palette index 0 (black) means transparent. Sampled as a flat
// wall texture it composites into mottled noise, which is what it looked like.
uniform sampler2D sky_tex : source_color, filter_linear, repeat_enable;
uniform bool has_sky_tex = false;
uniform float sky_scroll = 1.0; // 0 freezes it
// Quake's EmitSkyPolys. Direction from the eye to the surface, with the UP axis
// tripled to flatten the dome, renormalised to a fixed radius so the projection is
// independent of world units, then scrolled. The two layers move at different rates
// (Quake: solid x8, clouds x16) which is what gives the sky its parallax.
vec3 quake_sky(vec3 wpos, vec3 eye, float t) {
vec3 dir = wpos - eye;
dir.y *= 3.0; // Quake tripled Z; ours is Y-up
float l = 6.0 * 63.0 / max(length(dir), 0.0001);
vec2 st = vec2(dir.x, dir.z) * l; // Quake's x,y plane is our x,z
vec2 uv_back = fract((st + t * 8.0 * sky_scroll) / 128.0);
vec2 uv_front = fract((st + t * 16.0 * sky_scroll) / 128.0);
// Right half = solid, left half = clouds. Kept inside each half with a small
// inset so linear filtering cannot bleed one layer into the other at the seam.
const float INSET = 0.001;
vec3 back = texture(sky_tex, vec2(0.5 + clamp(uv_back.x, INSET, 1.0 - INSET) * 0.5, uv_back.y)).rgb;
vec3 front = texture(sky_tex, vec2( clamp(uv_front.x, INSET, 1.0 - INSET) * 0.5, uv_front.y)).rgb;
// Palette index 0 is transparent in the cloud layer. The palette turns it into
// black, so near-black is the transparency test.
float cloud = step(0.04, dot(front, vec3(0.333)));
return mix(back, front, cloud);
}
// The editor's MOVE PROXY (docs/editor-texturing-selection-move.md §4) is the brush's
// own mesh moved by transform. With texture lock ON the texture must ride with it, so
// the proxy sets this to its move delta and the PROJECTION reads world_pos - shift.
// Constant across the mesh, so the analytic derivatives below are unchanged. Zero —
// i.e. no effect — on the level itself.
instance uniform vec3 tex_lock_shift = vec3(0.0);
varying vec3 world_pos;
varying vec3 world_normal;
varying flat float v_mat;
varying flat float v_proj;
void vertex() {
world_pos = (MODEL_MATRIX * vec4(VERTEX, 1.0)).xyz;
// The N of the normal-map's TBN. Needed in WORLD space because the tangent frame
// comes from the projection table's u_axis/v_axis, which are world-space Quake
// texture axes — mixing those with view-space NORMAL would tilt every normal by
// the camera. Converted to view space once, at the end of fragment().
//
// A brush face is flat, so this is exact rather than an interpolated approximation
// — the one place the exact mesh makes normal mapping easier than the voxel path.
world_normal = normalize((MODEL_MATRIX * vec4(NORMAL, 0.0)).xyz);
// FLAT: material and projection index must NOT interpolate across a triangle,
// or adjacent faces bleed into a rainbow fringe at shared vertices.
v_mat = COLOR.r;
v_proj = CUSTOM0.x;
}
void fragment() {
vec3 color = vec3(0.8);
vec3 lod_color = vec3(0.0);
bool lod_hit = false;
// Captured inside the projection branch below and consumed after the sky test.
// The PBR atlases share tex_atlas's uv, layer and derivatives exactly, so this
// carries them out rather than recomputing (and risking them drifting apart).
vec2 pbr_uv = vec2(0.0);
float pbr_layer = 0.0;
bool pbr_valid = false;
vec2 pbr_ddx = vec2(0.0);
vec2 pbr_ddy = vec2(0.0);
vec3 pbr_T = vec3(1.0, 0.0, 0.0);
vec3 pbr_B = vec3(0.0, 1.0, 0.0);
if (use_textures) {
int prow = int(v_proj + 0.5) - 1; // table index is 1-based; <0 = none
if (has_projection_table && prow >= 0 && prow < projection_rows) {
// Row = 4 texels: (layer,u_off,v_off,0)(u_axis.xyz,0)(v_axis.xyz,0)(normal.xyz,0).
// Axes already divided by the texture's real width/height by the importer,
// because the atlas resized every texture to a square and discarded aspect.
vec4 t0 = texelFetch(projection_table, ivec2(0, prow), 0);
vec4 t1 = texelFetch(projection_table, ivec2(1, prow), 0);
vec4 t2 = texelFetch(projection_table, ivec2(2, prow), 0);
vec3 proj_pos = world_pos - tex_lock_shift;
vec2 raw = vec2(dot(t1.xyz, proj_pos) + t0.g, dot(t2.xyz, proj_pos) + t0.b);
// Derivatives taken ANALYTICALLY from world_pos, never from `raw`.
//
// raw depends on t1/t2, which are fetched with the FLAT varying v_proj.
// dFdx/dFdy run over a 2x2 pixel quad, and on a triangle edge that quad
// includes helper lanes covering the NEIGHBOURING triangle — which has a
// different flat v_proj, hence a different projection row and a wildly
// different raw. The derivative came out garbage, textureGrad selected an
// absurd mip, and the result was single blown-out texels scattered along
// every triangle edge. They appeared to move because which pixels land on
// edge quads changes with the camera.
//
// world_pos is a SMOOTH varying, so its derivative is well defined even
// across an edge, and t1/t2 are constant within the triangle. Same LOD
// inside a triangle, no cross-triangle contamination at the edges — and
// still derived from the CONTINUOUS uv rather than fract(), which is what
// killed the tile-seam glint in 0.746.
vec3 wpdx = dFdx(world_pos);
vec3 wpdy = dFdy(world_pos);
#ifdef RT
// No 2x2 quad in a hit shader, so dFdx/dFdy are zero stubs and textureGrad
// silently takes LOD 0 -- full-resolution texels at every distance.
// RAY_FOOTPRINT_* is the ray differential of exactly what this wants: the
// world-space size of one pixel at the hit, in the same units as
// dFdx(world_pos), so the projection below is unchanged.
//
// Shipped and reverted twice before (hyper-games #11 §4.6) because the
// footprint was ~1e6 too large -- projection_matrix[1][1] is negative under
// Vulkan's Y-flip and the engine's max(..., 1e-6) guard replaced it with the
// epsilon. Fixed engine-side with abs() (234159eac7). Verify with
// --lod-heatmap: this is right iff PT's median LOD lands on the
// rasterizer's 0.494, not 0.000 (stub) or 6.000 (unfixed footprint).
wpdx = RAY_FOOTPRINT_X;
wpdy = RAY_FOOTPRINT_Y;
#endif
vec2 ddx = vec2(dot(t1.xyz, wpdx), dot(t2.xyz, wpdx));
vec2 ddy = vec2(dot(t1.xyz, wpdy), dot(t2.xyz, wpdy));
if (debug_lod) {
// The mip textureGrad will pick: log2 of the longer footprint edge in
// texels. Ramped over R/G/B so a level can be read off the image and
// differenced numerically.
vec2 lod_ts = vec2(textureSize(tex_atlas, 0).xy);
float lod_rho = max(length(ddx * lod_ts), length(ddy * lod_ts));
float lod_raw = log2(max(lod_rho, 1e-6));
if (debug_lod_mode == 1) {
lod_color = vec3(VIEWPORT_SIZE.x / 4096.0, VIEWPORT_SIZE.y / 4096.0, 0.0);
} else if (debug_lod_mode == 3) {
// BINARY probes, not magnitudes. Emission passes through different
// exposure in the rasterizer and the path tracer, so an absolute
// value cannot be compared across them -- but a full-on/full-off
// channel survives any monotonic tonemap.
// R: is PROJECTION_MATRIX[1][1] populated? It is 1/tan(fovy/2),
// about 1.3. If the RT pass leaves it 0, rt_px = 2*t/max(P11*H,
// 1e-6) blows up by ~6 orders of magnitude, which is exactly the
// LOD saturation measured at --lod-mode=0.
// G: is VIEWPORT_SIZE.y populated? (already known good, kept as a
// positive control -- G must read high in BOTH renderers).
// B: constant 1.0, the "this pixel was shaded at all" reference.
lod_color = vec3(
PROJECTION_MATRIX[1][1] > 0.001 ? 1.0 : 0.0,
VIEWPORT_SIZE.y > 2.0 ? 1.0 : 0.0,
1.0);
} else {
float lod_max = (debug_lod_mode == 2) ? 20.0 : 6.0;
float lod_v = clamp(lod_raw, 0.0, lod_max) / lod_max;
lod_color = vec3(lod_v, 1.0 - abs(lod_v * 2.0 - 1.0), 1.0 - lod_v);
}
lod_hit = true;
}
#if defined(POM) && !defined(RT)
// Shift `raw` to where the view ray meets the height field. Everything below
// (albedo, normal, ORM) samples at the shifted uv; derivatives stay those of
// the face, which is the standard POM trade (no mip jump at relief edges).
if (use_pbr && use_orm) {
float hm = min(texelFetch(layer_params, ivec2(int(t0.r + 0.5), 0), 0).r, pom_max_depth_m);
float fade = 1.0 - smoothstep(pom_fade_start, pom_fade_end, length(CAMERA_POSITION_WORLD - world_pos));
float relief = hm * fade;
if (relief > 0.0) {
vec3 Nw = normalize(world_normal);
vec3 Vw = normalize(CAMERA_POSITION_WORLD - world_pos);
float vn = max(dot(Vw, Nw), 0.12);
// Per metre of depth the ray moves sideways by -(V - N vn) / vn. t1/t2
// are uv per metre, so projecting that onto them is EXACT — no tangent-
// space approximation.
vec3 side = -(Vw - Nw * vn) / vn;
vec2 dir = vec2(dot(t1.xyz, side), dot(t2.xyz, side)) * relief;
float layer_t = 1.0 / mix(float(pom_max_steps), float(pom_min_steps), vn);
float t = 0.0;
float d = 1.0 - textureGrad(orm_atlas, vec3(fract(raw), t0.r), ddx, ddy).a;
for (int i = 0; i < 64 && t < d; i++) {
t += layer_t;
d = 1.0 - textureGrad(orm_atlas, vec3(fract(raw + dir * t), t0.r), ddx, ddy).a;
}
float before = 1.0 - textureGrad(orm_atlas, vec3(fract(raw + dir * (t - layer_t)), t0.r), ddx, ddy).a
- (t - layer_t);
float after = d - t;
t = mix(t, t - layer_t, clamp(after / (after - before + 1e-6), 0.0, 1.0));
raw += dir * t;
}
}
#endif
color = textureGrad(tex_atlas, vec3(fract(raw), t0.r), ddx, ddy).rgb;
// Same uv, same layer, same derivatives — the PBR atlases are built layer-
// for-layer with the diffuse one. t1/t2 are the Quake texture axes VectorS
// and VectorT, which are the face's tangent and bitangent.
pbr_uv = fract(raw);
pbr_layer = t0.r;
pbr_ddx = ddx;
pbr_ddy = ddy;
pbr_T = t1.xyz;
pbr_B = t2.xyz;
pbr_valid = true;
} else if (none_plain) {
color = none_color;
} else {
// No projection for this face — flat per-material debug colour rather
// than a wrong texture, so missing projections are VISIBLE.
float mid = v_mat * 255.0;
color = fract(vec3(mid * 0.13, mid * 0.37, mid * 0.61)) * 0.7 + 0.15;
}
} else if (flat_mats) {
float mid = v_mat * 255.0;
if (use_palette) {
color = texelFetch(room_palette, ivec2(int(mid + 0.5), 0), 0).rgb;
} else {
// One flat colour per material id. Same hash as the no-projection path.
color = fract(vec3(mid * 0.13, mid * 0.37, mid * 0.61)) * 0.7 + 0.15;
}
} else {
// Untextured mode: flat grey. This is what the level looked like before
// texturing existed, and it remains the clearest view for judging edges.
color = vec3(0.75);
}
// Sky faces: replace the flat atlas sample with the real two-layer Quake sky, and
// EMIT it. A sky brush is a light source in the fiction, not a lit surface — the
// stained-glass read — so it glows its own colour rather than being shaded.
//
// NO EARLY RETURN. Godot's shading language forbids `return` in a processor
// function, and an early return here failed the WHOLE shader to compile — which
// untextured the entire level, not just the sky. (Liquids and teleport pads kept
// their textures because LiquidVolume carries its own material, which is what
// made the breakage look partial.) Branch and fall through instead.
float sky_id = v_mat * 255.0;
bool is_sky = any(lessThan(abs(vec4(sky_id) - sky_mats), vec4(0.5)));
if (is_sky && has_sky_tex) {
color = quake_sky(world_pos, CAMERA_POSITION_WORLD, TIME);
}
ALBEDO = color;
ROUGHNESS = 0.9; // fallback when there is no PBR set; overwritten just below
METALLIC = 0.0;
// PBR. Skipped for sky faces: a sky brush is a light source in the fiction and
// gets its emission from the sky texture below, so a glow lookup would fight it.
//
// pbr_full drops the roughness and normal work on INDIRECT bounces while keeping
// glow unconditional — a bounce reaches the image only through the throughput,
// where a normal map is not distinguishable, but glow emits the light the bounce
// exists to gather. PRIMARY_HIT is RT-only and #ifdef RT is defined solely for the
// RT variant, so the rasterizer still compiles the full path.
if (use_pbr && pbr_valid && !is_sky) {
bool pbr_full = true;
#ifdef RT
pbr_full = PRIMARY_HIT;
#endif
if (use_orm) {
if (pbr_full) {
vec3 orm = textureGrad(orm_atlas, vec3(pbr_uv, pbr_layer), pbr_ddx, pbr_ddy).rgb;
ROUGHNESS = orm.g;
METALLIC = orm.b;
#ifndef RT
// Raster only: AO is a screen-space-lighting input, and the path tracer
// computes occlusion itself — writing it there would darken twice.
AO = orm.r;
#endif
}
} else {
vec4 gr = textureGrad(glow_atlas, vec3(pbr_uv, pbr_layer), pbr_ddx, pbr_ddy);
EMISSION = gr.rgb;
if (pbr_full) {
// gloss is a SPECULAR map: matte base, sharpening only where it is glossy.
ROUGHNESS = 1.0 - gr.a * 0.7;
}
}
if (pbr_full) {
vec3 Nn = normalize(world_normal);
// normalize: the projection table has already divided the axes by the
// texture's real width/height, so they are not unit length here.
vec3 Tn = normalize(pbr_T);
vec3 Bn = normalize(pbr_B);
// DarkPlaces decode: rgb-0.5, not the usual rgb*2-1. The atlas is written
// by _dp_normal_from_bump, which follows DP's own encoding.
vec3 sn = normalize(textureGrad(norm_atlas, vec3(pbr_uv, pbr_layer), pbr_ddx, pbr_ddy).rgb - 0.5);
sn.xy *= normal_strength;
// DP: sn.x*VectorS + sn.y*VectorT + sn.z*VectorR — no sign flip, +v_axis is +V.
vec3 wn = normalize(sn.x * Tn + sn.y * Bn + sn.z * Nn);
NORMAL = normalize((VIEW_MATRIX * vec4(wn, 0.0)).xyz);
}
}
if (is_sky) {
EMISSION = color * sky_emission;
}
if (debug_lod) {
// Black albedo + emitted ramp: the value survives identically through the
// rasterizer and the path tracer, with no lighting or sampling noise on top.
ALBEDO = vec3(0.0);
EMISSION = lod_hit ? lod_color : vec3(0.0);
}
}