OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / maps / dc_level.gdshaderinc

419 lines · gdshaderinc ref:
// 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);
	}
}