Skip to content

dh.material

Extension: .dh-mat
Type ID: dh.material

Materials define surface appearance: textures, PBR properties, tint, and transparency. DigitalHeaven materials use semantic properties that map to platform-specific shaders at build time.

PropertyTypeRequiredDescription
$type"dh.material"noType identifier
namestringnoDisplay name
descriptionstringnoDescription
inheritsstringnoBarcode of the material to inherit from
texturesobjectnoTexture channel assignments
propertiesobjectnoFloat material properties
tintcolornoBase color multiplier (RGBA), authored in sRGB (hex = face value)
emissiveColorcolornoEmissive color, authored in sRGB in [0, 1] (default: none/black)
emissiveIntensityfloatnoLinear HDR multiplier for emissiveColor (default: 1)
bakeEmissionboolnoWhether this material’s emission (emissiveColor times emissiveIntensity, masked by its emissive texture) lights the map’s lightmap as well as glowing itself. A part override’s bakeEmission wins over it
uvScale[u, v] or { "u", "v" }noScale applied to texture UVs before sampling (default: [1, 1])
uvScroll[u, v] or { "u", "v" }noUV animation — how fast the textures slide, in UV units per second (default: still)
flipbookobjectnoUV animation — the textures are an atlas of animation cells played off the world clock
alphaModestringnoAlpha rendering mode
alphaCutofffloatnoCutoff threshold for "mask" mode (default: 0.5)
blendModestringnoHow a blended surface combines with the frame: "alpha" (default), "additive", "multiply" or "mod2x"; read only while alphaMode blends
depthBiasfloatnoDepth bias — steps the surface is pulled toward the camera so it wins against the surface it lies on, 0 to 16 (default: 0)
shaderstringnoSemantic shader ("lit", "unlit" or "water")
renderFacestringnoFace culling ("front", "back", or "both")
surfaceobjectnoPhysics/surface (surfaceprop) block — how the material behaves underfoot
detailobjectnoDetail layer — a second channel set with its own uvScale
parallaxobjectnoParallax march switches — shadow strength and depthWrite
stochasticboolnoStochastic sampling — hides tiling repetition, at ~3× the fetch cost (default: false)
decalsarraynoDecals — up to four colors laid over the base albedo, each on its own UV set and shaped by a mask channel
waterobjectnoWater — the material is a water surface: a preset plus optional transmittanceColor / atDistance / scatter / scatterColor / fogColor / waveScale / chopScale / foamWidthMeters / foamColor / chopWavelengthMeters / density / viscosity

The type is inferred from the file extension, so $type is not needed in source files. The compiler adds it automatically during builds.

glass.dh-mat
{
"name": "Glass",
"textures": {
"baseColor": "textures/glass-albedo.png",
"normal": "textures/glass-normal.png",
"metallicRoughness": "textures/glass-pbr.png"
},
"properties": {
"metallic": 0.0,
"smoothness": 0.95
},
"tint": [0.9, 0.95, 1.0, 0.5],
"alphaMode": "blend"
}

A material’s field set is closed exactly as its channel set is: a top-level member the schema does not define is a compile error naming the member, never a field that quietly does nothing. (The one deliberate exception is properties, an open float bag.)

The textures object maps semantic channel names to texture file paths. The set is closed — an unrecognized name is a compile error, not a silently ignored key:

ChannelKindSampledDescription
baseColorcolorRGBAlbedo / diffuse color, multiplied by tint
normaldataRGBTangent-space normal map, unpacked as t × 2 − 1
metallicRoughnessdataG, BCombined metallic-roughness (glTF packing: roughness in G, metallic in B)
occlusiondataRAmbient occlusion; darkens ambient only, never direct light
emissivecolorRGBEmission mask, multiplied into emissiveColor × emissiveIntensity
heightdataRDisplacement field for parallax occlusion mapping, scaled by heightScale

Paths are read from the material file’s own folder — a leading / reads them from the pallet root instead — or barcodes for cross-pallet references, which may name a .dh-tex in the other pallet (see how). The detail block’s texture paths follow the same rule.

A channel entry is either a bare string (the path, sampled on UV0) or an object carrying texture plus uvSet:

{ "textures": { "normal": "textures/seam-normal.dh-tex" } } // UV0
{ "textures": { "normal": { "texture": "textures/seam-normal.dh-tex", "uvSet": 3 } } }
PropertyTypeRequiredDescription
texturestringyesThe channel’s texture path, same rules as the bare-string form
uvSetintnoWhich of the mesh’s UV sets this channel samples, 0 – 3 (default: 0)

uvSet follows the same range and error style as a decal’s uvSet: a value outside 0 – 3 is a compile error naming the channel. height accepts the field but has nothing to do with it yet — parallax occlusion mapping always marches UV0, so a uvSet authored on height has no effect at runtime.

The channel set is closed: a channel outside the table above is a compile error, never a silently ignored key. Each channel samples the mesh’s UV0 by default, and may name one of the mesh’s other UV sets instead through its own uvSet (see above) — a seam normal authored on a model’s second unwrap, for instance, samples uvSet: 3 while the rest of the material stays on UV0. A second set of maps at a second tiling rate, rather than a second UV set for one channel, is the detail layer, which is a block of its own rather than more channel names here. Coming from a Unity Standard material, see Porting from Unity → What has no DigitalHeaven equivalent.

You can reference individual channels from packed textures using @channel syntax:

skin.dh-mat
{
"textures": {
"metallicRoughness": "textures/packed-orm.png",
"occlusion": "textures/packed-orm.png@r"
}
}

The compiler extracts the specified channel at build time and generates a separate grayscale texture. Valid channels: r, g, b, a.

See dh.texture for more on channel operations.

PropertyRangeDefaultEngineDescription
metallic0.0 – 1.00✅How metallic the surface is (0 = dielectric, 1 = conductor)
roughness0.0 – 1.01✅Surface roughness (1.0 = fully rough)
occlusionStrength0.0 – 1.01✅How strongly the occlusion map darkens ambient (0 ignores it)
heightScale0.0 – 1.00✅Depth of the height field, in UV units — see parallax occlusion
heightReference0.0 – 1.00✅Where the mesh surface sits in the height field — see the reference plane
smoothness0.0 – 1.0——Inverse of roughness (1.0 = mirror-smooth)
normalScale0.0 – 8.01✅Strength of the normal map — see normal strength
emissiveIntensityany1✅See emissive color — a top-level field, not a properties key

The Engine column marks what the Engine map renderer consumes today. Every key above except emissiveIntensity lives in the properties object; smoothness is for consumers that read it (the Unity runtime).

properties is an open float bag — unlike the closed texture channel set, an unrecognized key is not a compile error, it is simply ignored by any consumer that does not know it. That is what lets smoothness pass through to the Unity runtime without the Engine having to define it. A non-finite value (NaN, infinity) falls back to the default rather than poisoning the shader.

Open does not mean unwatched. A key none of the known consumers read is far more often a typo than a deliberate one, so the compiler emits a warning — never an error, since a build must not fail over a key meant for a consumer this toolchain has never heard of:

warning Unrecognized material property 'metalic'
in 'Concrete' (my.pallet:materials/concrete.dh-mat). Did you mean 'metallic'?

The warning names the material and the key, and suggests the nearest recognized key when the two are within a couple of characters of each other; otherwise it lists the recognized set. Recognized is the union of every key a known consumer reads — the Engine column above, plus smoothness, emissiveIntensity and cutoff for the Unity runtime. Passthrough is unchanged either way: a warned key still lands in the compiled material exactly as authored.

The Range column is enforced for the keys the Engine reads. The renderer clamps to exactly that range, so a value outside it would have shipped as a quietly smaller number — normalScale: 40 rendering as 8 with nothing said anywhere. It is a compile error instead:

error Material property 'normalScale' is 40
in 'Concrete' (my.pallet:materials/concrete.dh-mat). 'normalScale' must be between 0 and 8.

Only Engine keys are range-checked. smoothness and every unrecognized key still pass through untouched — their range belongs to whoever reads them.

The defaults are deliberately not glTF’s: a material that says nothing about metalness is a plain painted surface, so metallic defaults to 0 rather than glTF’s 1.

A bound metallicRoughness texture multiplies these scalars rather than replacing them, so leaving both at their defaults and binding a map hands the texture full control of roughness, while a scalar of 0.5 halves whatever the map says.

Use either roughness or smoothness, not both. They’re inverses of each other. Converting a Unity material means writing roughness = 1 − smoothness — see Porting from Unity.

The Engine renders in a linear color pipeline: lighting runs in linear light and the swapchain re-encodes to sRGB on present (see textures). Authored colors — tint and emissiveColor — are sRGB face values (a hex like #FFC9C7 displays as exactly that color), and the renderer linearizes them before lighting. You author colors the way they should look; the pipeline handles the rest.

The scene is rendered into an offscreen half-float HDR target, not straight to the display, so radiance above 1.0 survives shading instead of clipping at write time. A fullscreen tonemap pass then resolves it: the optional Panini resample, then exposure, then the bloom composite, then a curve, then the optional RCAS sharpen, then dither.

Those do not belong to the same owner, and the split follows what each one is for. Art direction — the exposure, the tonemap curve and the bloom look — is world-authored, because the map author decides the mood and two players standing in the same room should be looking at the same one. Cost and comfort — the field of view and its axis, the Panini projection, the sharpen, the lens effects, dither, shadow resolution, shadow distance and the parallax fade — is client-only, because a map author has no business setting your FOV and performance belongs to whoever owns the GPU.

SettingDefaultWhat it does
world.render.exposure1Linear multiplier applied to scene radiance before the curve
world.render.tonemapreinhardWhiteCurve: reinhardWhite, aces (filmic shoulder), reinhard, hable (Uncharted 2; coefficients below), or none (raw clipped radiance — the debug view)
world.render.hableShoulderStrength, hableLinearStrength, hableLinearAngle, hableToeStrength, hableToeNumerator, hableToeDenominator, hableWhitePoint0.15, 0.50, 0.10, 0.20, 0.02, 0.30, 11.2The hable curve’s coefficients and white point; read only while the curve is hable. See the hable block
world.render.bloomtrueWhether the world’s bright areas bloom
world.render.bloomIntensity0.046Linear multiplier the resolved bloom pyramid is added back with. Clamped to 0–4
world.render.bloomThreshold1.721Linear radiance (max channel) above which a pixel blooms. Clamped to 0–64
world.render.bloomSoftKnee0.5Width of the knee below the threshold, as a fraction of it. Clamped to 0–1
world.render.bloomDiffusion6.2How far the bloom spreads, in pyramid levels. Clamped to 1–10
world.render.bloomAnamorphic-0.24Aspect distortion of the glow. Clamped to -1–1
client.render.dithertrueAdds triangular-PDF interleaved gradient noise one 8-bit step wide, dissolving the banding smooth gradients otherwise show
client.render.sharpenfalseRCAS sharpening of the tonemapped result. Client-only, and independent of Panini
client.render.sharpenStrength0.5Strength of that sharpen over 0–1. 0 is an exact identity
client.render.vignettefalseVignette: darkens the frame corners on linear radiance, before the curve
client.render.vignetteIntensity0.35How dark the corners go over 0–1. 0 is an exact identity
client.render.vignetteSmoothness0.5Width of the vignette falloff as a fraction of the frame radius. Clamped to 0.05–1
client.render.chromaticAberrationfalseChromatic aberration: splits red and blue radially outward
client.render.chromaticAberrationStrength0.35How far they separate at the corners, over 0–1. 0 is an exact identity
client.render.parallaxFadeStart3Mip level the parallax march starts fading out at. Clamped to 0–16
client.render.parallaxFadeLength5Mip levels that fade spans. Clamped to 0–16; 0 is a hard cutoff
client.render.parallaxShadowTaps16Taps the height-field self-shadow marches toward each light. Whole numbers, 0–32; 0 turns it off
client.render.parallaxShadowLightstrueWhether that self-shadow also marches toward point and spot lights, not only the sun
client.render.parallaxDepthWritetrueWhether materials asking for the marched depth get it. A kill-switch, not a look

The eight world.render.* values are replicated and server-authoritative, exactly like world.sun.*. A map seeds them from its optional render block; an admin retunes them live from the console and every client follows.

A player can still overrule any of them locally, and those overrides are unset by default:

OverrideDefaultWhat it does
client.render.exposureunsetWhile unset, follows world.render.exposure. Setting it pins your own value
client.render.tonemapunsetWhile unset, follows world.render.tonemap. Setting it pins your own curve
client.render.bloomunsetWhile unset, follows world.render.bloom. Setting it pins bloom on or off for you
client.render.bloomIntensityunsetWhile unset, follows world.render.bloomIntensity
client.render.bloomThresholdunsetWhile unset, follows world.render.bloomThreshold
client.render.bloomSoftKneeunsetWhile unset, follows world.render.bloomSoftKnee
client.render.bloomDiffusionunsetWhile unset, follows world.render.bloomDiffusion
client.render.bloomAnamorphicunsetWhile unset, follows world.render.bloomAnamorphic

“Follows the world” has one layer in between: a server may hand a single player their own look through players.<target>.*, and that lands below the local override. The full order, outermost winning, is client.render.* → that player’s replicated look override → world.render.* → the engine default — so a player’s own console edit still beats what the server hands them.

Two bloom preferences have no world counterpart at all, because neither is art direction — one is cost and one is a diagnostic, so no map may set either:

PreferenceDefaultWhat it does
client.render.bloomFastfalseSwaps the quality filters for a 4-tap box on the way down and up. Cheaper, and visibly blockier under camera motion — which is why quality is the default
client.render.bloomDebugLevel-1Diagnostic: composites one pyramid level on its own instead of the finished bloom, so each rung can be inspected in isolation. -1 renders normally, 0 is the prefiltered half-res base, higher levels the progressively wider blurs. A level past the end of the derived chain simply renders normally

Reading one tells you which of the two states it is in, and reset puts it back to following the world:

] client.render.exposure
client.render.exposure = 1.6 (from world; not overridden)
] client.render.exposure 2.2
] client.render.exposure
client.render.exposure = 2.2 (overriding world 1.6)
] reset client.render.exposure

Because an unset override follows rather than caching a copy, a world’s look reaches every player who has not deliberately opted out — including one who was already connected when it changed.

The UI renders after the resolve, so the console and overlay are never tonemapped. Exposure, the tonemap curve and dither are console-only today; the Video section of the settings screen exposes the field of view, the Panini projection and the bloom controls, but no tonemapping controls.

Because the clear color now passes through the same curve as everything else, an empty scene reads slightly darker than it did before the HDR target landed. That is the tonemap doing its job, not a lost background.

Bright things bleed light into their surroundings, because real lenses scatter. The engine reproduces that with a progressive downsample/upsample pyramid run entirely in fragment shaders between the scene pass and the resolve. The filters are Jorge Jimenez’s, from Next Generation Post Processing in Call of Duty: Advanced Warfare (SIGGRAPH 2014); the firefly-suppressing average is Brian Karis’s.

  1. A prefilter reads the HDR scene through the 13-tap downsample below, applies exposure, clamps to the largest finite half-float value, thresholds, and writes a half-resolution base — mip 0 of the pyramid. Exposure is applied here, not at composite time: the threshold is a statement about how bright a pixel looks, so a world that dials its exposure down should bloom less rather than bloom the same and then be dimmed. It filters first and thresholds second — the filter’s job is to find the neighborhood’s representative radiance, and thresholding each tap first would throw that neighborhood away.
  2. A 13-tap box downsample halves the base repeatedly — five overlapping 2×2 boxes, center weighted 0.5 and corners 0.125 each. The overlap is what removes the pulsing a naive 2×2 halving shows when the camera moves a sub-texel amount.
  3. A 9-tap tent upsample walks back up, and each rung adds the same-size downsample level it passes. Summing on the way up is what makes the result a stack of blurs at geometrically increasing radii rather than one very wide one: the narrow rungs keep a bright core around small sources, the wide ones carry the broad haze.

Every tap sits at a half-texel position and is read through a bilinear, edge-clamped sampler, so the texture unit does half the averaging for free. That is load-bearing, not an optimization — a point sampler would silently turn the 13-tap box into a sparse, aliasing comb.

The chain’s length is computed from the render resolution and bloomDiffusion:

levels = clamp(floor(log2(max(baseWidth, baseHeight)) + diffusion - 10), 1, 8)

A fixed level count would make the widest rung a constant number of pixels, so the glow would visibly narrow as the resolution rose. Deriving it keeps the widest rung a constant fraction of the screen, which is what makes the look resolution-independent: at the default diffusion the chain runs six levels at 1080p and 1440p and seven at 4K, and the glow is the same size on screen at all three.

The fractional part of that same expression is carried into the tent’s footprint (a sample scale over 0.5–1.5), so the width grows continuously as the resolution or the diffusion rises instead of doubling the instant a whole level appears — dragging a window edge does not make the glow step.

Two floors bound it: the ceiling of 8 levels the pyramid images are built for, and a minimum of 8 texels on either axis. The deepest rungs of a small chain cost two pipeline barriers and a draw launch to blur almost nothing, so they are dropped rather than run.

A single pixel carrying a thousand times its neighbors’ radiance — a specular glint on a moving edge — would otherwise survive the whole pyramid as a crawling blob, because the blur spreads it instead of removing it. So each 2×2 sub-group of the first downsample is weighted by 1 / (1 + luma) before it is summed and the total renormalized. The weighted mean is a partial median: the outlier is pulled toward its neighborhood rather than dominating it.

It is applied on the first downsample only. Raw single-pixel outliers exist only in the unfiltered scene, and applying the weighting deeper would darken the blur and break its energy behavior. Each tap is scaled by exposure before its weight is taken, because the weight is a statement about how far a tap stands out in the image as it will actually be seen — weighting unexposed radiance would make the suppression drift as a world’s exposure was retuned.

client.render.bloomFast drops the average along with the quality filters: the cheap prefilter is a plain 4-tap box, so fireflies come back with it.

The bright pass is a quadratic soft knee: below threshold − knee the result is exactly zero, above threshold + knee it is exactly the hard cut, and the quadratic between them meets both with matching value and slope. bloomSoftKnee sets the knee width as a fraction of the threshold; 0 is a hard cut and pops as a light brightens across it.

What it tests is a pixel’s brightest channel, not its luminance. Luminance is weighted for the eye — a saturated blue reads at 0.07 of its own value — so a luminance-keyed threshold would make a blue neon need to be roughly six times brighter than a white light before it bloomed at all. Keying on the max channel means a saturated light blooms at the intensity it was authored at.

bloomAnamorphic shrinks one axis’s divisor when the half-resolution base is sized, so the base keeps more texels on one axis than the other. A fixed texel radius then covers a smaller fraction of the screen along the higher-resolution axis, and the glow comes out oval: negative ratios keep more horizontal texels and stretch the glow vertically, positive ratios do the reverse, and 0 is round. The default -0.24 is a gentle vertical stretch — well below the obvious cinematic streak.

Unlike every other bloom setting, bloomDiffusion and bloomAnamorphic change the pyramid’s geometry, so they rebuild the two bloom images between frames after a device idle — exactly as a shadow-resolution change does. Live, but not free. Everything else rides a push constant and applies on the very next frame.

The bloom is added inside the resolve, after the exposure multiply and before the tonemap curve. That is the only physically meaningful place for it: bloom is light that scattered in the lens before the sensor saw it, so it must be added to the scene in linear radiance and tonemapped along with it, never added to an already-tonemapped image.

It is sampled at the same Panini-warped coordinate the scene is, so the glow rides the warp with the geometry it belongs to instead of sliding off it.

Turning bloom off is exactly the pre-bloom image, bit for bit, not a very faint one: the composite is a uniform branch on a push constant, so the sample is genuinely not taken. An intensity of 0 is the same identity, since x + 0 is x.

Video carries a Bloom toggle, and switching it on reveals Bloom intensity, Bloom threshold, Bloom soft knee, Bloom diffusion, Bloom anamorphic and Fast bloom filters beneath it — the same reveal the Panini strength slider uses. The preferences stay valid and settable from the console whether or not bloom is on.

Each of those controls writes the client override, which is unset until you touch it, and reads through the live world look — so an untouched slider shows what the world you are standing in is currently asking for rather than a fixed engine default. reset client.render.bloomIntensity puts it back to following the world. client.render.bloomDebugLevel is console-only.

Before the scene is drawn, the sun renders it once per cascade into a three-layer depth map and the opaque shader samples that to attenuate the direct sun term only — ambient and every analytic light are untouched, so a shadowed surface keeps its hemisphere fill and the half-Lambert wrap keeps it softly lit rather than black. That is the intended look, not a missing occlusion term.

Whether the sun casts is world.sun.shadows — replicated, like the rest of world.sun.*, because two players in the same doorway must agree on whether it is in shadow. What it costs is local:

PreferenceDefaultWhat it does
client.render.shadowResolution2048Side length in texels of each of the three cascades. Halving it quarters the memory and fill cost for a coarser shadow edge. Applies live — the shadow map is rebuilt between frames
client.render.shadowDistance120How far from the eye shadows are drawn, in meters. The three cascades tile exactly this range; shortening it packs the same texels into less world and sharpens nearby shadows
client.debug.cascadesfalseTints shading by cascade — red, green, blue near to far, flat gray past the shadow distance — so cascade coverage is directly visible

The diffuse wrap is not here: it is world.lighting.halfLambert, a replicated world setting rather than a per-viewer one, because two players standing in the same room must agree on how hard the terminator falls. See dh.map → The world’s sun and ambient.

A map’s material table names one dh.material per slot, and a reference can go stale — a renamed asset, a pallet that no longer carries the file, a broken inherits link anywhere up the chain. That never fails the map load. The slot whose reference will not fold is left unbound and draws the built-in red/black missing-material checker, every other slot in the table is built exactly as it would have been, and the map comes up. The console gets one error per slot naming the slot, the reference, the map’s pallet and the underlying reason; the load raises a single toast counting them (N material slots missing, see console), and the inspector’s material row for that slot carries the same sentence on hover. The fix is always to rebuild the pallet the reference points into.

The checker wins over a partial fold: when the failure is in the parent of a child material, the child’s own fields still exist but the fold is missing the level everything was layered over, so the slot gets the checker rather than a half-inherited material that quietly looks almost right. The server, which has no renderer, simply keeps the world default grip for that slot and cooks collision as usual. A material that does resolve but references a texture that is missing is a different case and still fails the map load — the texture read is part of the upload, not the fold.

The surface block is a material’s surfaceprop — how the surface behaves, independent of how it looks. One material barcode carries both a look (textures, tint, shader) and its physical behavior, following the Source engine model. It is a sub-object (not a bare float) so future surface data — footstep sounds, impact effects — can extend it without reshaping the material.

ice.dh-mat
{
"name": "Ice",
"textures": { "baseColor": "textures/ice.png" },
"surface": {
"frictionMultiplier": 0.15
}
}
PropertyTypeRequiredDescription
presetstringnoOne of the shipped surface presets below (default: stone)
kindstringnoWhat the surface is — one of stone, metal, wood, dirt, grass, gravel, sand, glass, flesh, plastic, fabric, snow, ceramic, ice. Authored independently of preset: naming a kind says what the surface is without also picking which preset’s numbers back it (default: the preset’s own kind)
frictionMultiplierfloatnoGrip a player standing on this surface experiences, 0.0 – 1.0 (default: the preset’s)
densityfloatnoMass per cubic meter, in kg/m³; must be positive (default: the preset’s)
hardnessfloatnoHow rigid the surface is, 0.0 – 1.0 (default: the preset’s)
penetrationModifierfloatnoA multiplier over how easily a projectile penetrates this surface; must not be negative (default: the preset’s)
damageModifierfloatnoA multiplier over damage dealt on this surface; must not be negative (default: the preset’s)
footstepSetstringnoWhich footstep sound set plays walking on this surface (default: the preset’s)
scrapeSetstringnoWhich scrape sound set plays sliding against this surface (default: the preset’s)
impactSetstringnoWhich impact sound set plays a projectile hitting this surface (default: the preset’s)
acousticMaterialstringnoWhich acoustic material this surface reflects/absorbs sound as (default: the preset’s)

The block is closed: an unknown field, an unknown preset or kind, a frictionMultiplier outside 0.0 – 1.0, a non-positive density, a hardness outside 0.0 – 1.0, or a negative penetrationModifier/damageModifier is a build error.

frictionMultiplier is the surface’s grip: it scales ground acceleration, ground friction and ground-stick together. 1.0 is full grip (an ordinary floor); lower is slidy (a slime or ice surface keeps speed and gives less control); 0.0 is frictionless and never sticks the player to the ground. A material with no surface block at all behaves exactly as before (full grip), so existing maps are unchanged.

The grip is read from the dh.material of the exact surface under the player’s feet: when a map’s collision is cooked from its render mesh (autoCollision: "mesh"), every collision triangle is keyed to the material of its render slot, and the mover samples that triangle’s grip each tick. A box brush or an authored collider carries the surface of the material it names for its whole body. A voxel volume has no materials: each block’s row in its block table names a surface preset, and its collision triangles carry that preset as though a material had named it. A surface with no per-material grip (a material with no surface block, or a collider naming no material) uses the full-grip default, so existing maps are unchanged.

Grip scales how fast you accelerate, brake and stick on a surface. It does not by itself pull a stationary player downhill: a player standing still on a low-grip flat surface stays put (grounded gravity is zeroed, Source-faithful). Slopes still slide via the slope limit, independent of grip.

Naming a preset resolves every field the block does not author itself — the same way a water preset resolves wave, chop and foam. Each preset also carries the mandated real-world density for its material, which is the source of a cooked prop’s mass (mass = density × the prop’s enclosed volume, before any per-body mass override).

presetkindfrictionMultiplierdensity (kg/m³)hardness
stone (default)stone1.024000.9
metalmetal0.978500.95
woodwood0.97000.5
dirtdirt0.8515000.2
grassgrass0.812000.15
gravelgravel0.7518000.35
sandsand0.716000.1
glassglass0.9525000.85
fleshflesh0.610500.1
plasticplastic0.859500.4
fabricfabric0.73000.05
ceramicceramic0.9522000.8
iceice0.129170.6

Ice slides: friction 0.12, the lowest of any preset.

Water is not a surface kind — a water body is a water block, not a surface one, and the two are mutually exclusive on a sane material.

A material’s frictionMultiplier is the base value. A world can override the grip of any material at runtime — per world, per instance, or from a patch — without editing the material asset, exactly like the per-player movement overrides. The override is keyed by the material’s barcode, is server-authoritative, and is replicated to clients so predicted and authoritative movement always agree on how slidy each surface is. Resolution is override ?? base ?? full-grip.

The tint property is an RGBA color multiplier applied to the base color texture, authored in sRGB. If there’s no base color texture, tint defines the solid color.

finalColor = texture * linearize(tint)

The RGB is linearized; the alpha passes through unchanged (it is coverage, not a color). A mesh that carries a COLOR_0 multiplies its base color by that too, per vertex. Two formats:

Array (RGBA floats, 0–1):

{ "tint": [1.0, 0.5, 0.5, 0.8] }

Hex string (RGB or RGBA):

{ "tint": "#FF8080CC" }
{ "tint": "#FF8080" }

Hex without alpha defaults to fully opaque (FF).

emissiveColor is a color the surface radiates independently of scene lighting, added on top of the lit shading result. It is an sRGB color in [0, 1] (exactly like tint), and its HDR brightness comes from the separate emissiveIntensity scalar — you no longer stuff values greater than 1 into the color:

litColor = albedo * lighting + linearize(emissiveColor) * emissiveIntensity * emissiveMask

emissiveIntensity is a linear multiplier (default 1); values above 1 push the surface past display white so it reads as “hot”. Those values are real: the scene renders to an HDR target and the tonemap pass rolls them off toward white on its shoulder rather than clipping them flat, and the bloom prefilter picks the same values up — an emissive pushed past the bloom threshold glows into its surroundings. Absent emissiveColor means no emission (black). Emission is ignored by an unlit material (which emits only tint×base).

{
"emissiveColor": [1.0, 0.48, 0.0], // sRGB orange
"emissiveIntensity": 2.0 // HDR brightness (e.g. a glowing lamp)
}

The emissive texture channel is the mask that decides where on the surface the emission applies. It is a straight RGB multiply into the HDR emissive color — the same setup Unity’s Standard shader uses when an albedo is dropped into _EmissionColor:

emission = linearize(emissiveColor) * emissiveIntensity * texture(emissive, uv).rgb

Without it the whole object glows uniformly, which is almost never what a lamp, a sign or a console wants: the housing glows just as hard as the bulb. Bind the same texture the albedo uses (or a purpose-authored mask) and only its bright texels emit.

An unbound emissive channel samples a neutral 1.0 white, so the product collapses back to the flat emissiveColor × emissiveIntensity pair — every material written before masks existed renders exactly as it did.

lamp.dh-mat
{
"textures": {
"baseColor": "/textures/lampTexture.png",
"emissive": "/textures/lampTexture.png" // same texture as the mask
},
"emissiveColor": [1.0, 0.48, 0.0],
"emissiveIntensity": 2.0
}

uvScale multiplies the mesh’s UV coordinates before the material’s textures are sampled, so a texture can tile (values > 1) or stretch (values < 1) without re-authoring the mesh UVs. It is applied uniformly to every texture channel. Default is [1, 1] (no scaling).

Two formats — array or object:

{ "uvScale": [3, 3] } // tile 3× in both axes
{ "uvScale": { "u": 3, "v": 3 } }

Two fields move a material’s textures over time, both driven by the world clock the shaders already read (the one the water waves run on). Both are a pure function of that clock, so an offscreen render at --worldSeconds 1 shows exactly what a player sees one second in, and the same clock time always draws the same picture.

uvScroll is a rate per axis in UV units per second, applied after uvScale: [0.25, 0] crosses a quarter of the texture each second whatever the tiling, and [1, 0] crosses it whole. The sign is the direction the picture travels in UV.

flipbook treats the textures as an atlas of equal cells and shows one at a time, row by row from the top-left, the order Source’s $frame and every sprite sheet use.

conveyor.dh-mat
{
"textures": { "baseColor": "textures/conveyor.png" },
"uvScroll": [0.25, 0]
}
caustics.dh-mat
{
"textures": { "baseColor": "textures/caustics.png" },
"alphaMode": "blend",
"flipbook": { "columns": 8, "rows": 8, "frames": 60, "fps": 30 }
}
FieldTypeRequiredDescription
uvScroll[u, v]noUV units per second on each axis (default: [0, 0], still)
flipbook.columnsintnoCells across the atlas, 1 – 64 (default: 1)
flipbook.rowsintnoCells down the atlas, 1 – 64 (default: 1)
flipbook.framesintnoHow many cells play, counted from the first, 1 – columns × rows; the rest of a part-filled atlas is never shown (default: every cell)
flipbook.fpsfloatnoFrames per second, 0 – 240; 0 holds the first frame (default: 30)
flipbook.loopboolnoRepeat the animation (default: true)

The field set is closed, and a cell count, frame count or rate outside its range is a build error. The flipbook folds whole through inherits; uvScroll folds like uvScale.

Every base channel moves together (the albedo, normal, metallic-roughness, occlusion and emissive maps), so a scrolling conveyor keeps its relief and its glow with it. The animation reaches the base channels only: the detail layer and decals keep their own placement, and the matcap is addressed by the view normal, not by UVs. It composes with alphaMode, so a scrolling or flipbook material can be opaque, masked or blended.

Details worth knowing:

  • A flipbook repeats its current cell. The UVs are scaled, scrolled, then wrapped into the cell, so a tiled surface (uvScale above 1) shows the same frame in every tile rather than a strip of the atlas.
  • Atlas cells bleed by half a texel under filtering. Leave a gutter, or keep the cell’s outer pixels the same color as its neighbors’, if a frame edge shows.
  • A flipbook that does not loop plays once from clock zero and holds its last frame. There is one world clock and no per-material start time, so on a world running longer than frames / fps seconds a non-looping flipbook is a still of its last frame. Use it for captures and fixed states; leave loop on for anything a player watches.
  • The clock wraps every client.render.worldTimeWrap seconds (default 3600) to keep float precision. A scroll or a flipbook shows a one-frame jump at the wrap unless rate × period is a whole number of textures, and frames divides fps × period.
  • Cost: a material that animates takes one row in the per-material layer table it shares with its detail, matcap and envmap blocks, shared by every material whose row comes out the same (32,768 distinct rows a scene). A still material costs nothing.
  • Shadows and the AO prepass read an alpha-masked material’s texture at its still UVs, so a scrolling cutout casts the shadow of its first frame.
PlatformUV animation
Engine, Windows desktop✅
Engine, Linux and macOS desktop❔
Engine, iOS❔ the shader is shared; not yet run on a device
Unity, BONELAB, Garry’s Mod and the other game platforms❌ the textures stay still

normalScale scales the decoded tangent-space normal’s XY before it is renormalized, so it tilts the shading normal further from — or closer to — the surface without touching the map:

  • 0 is flat: the normal map contributes nothing.
  • 1 (default) is the map as authored.
  • Above 1 exaggerates it. 1.25 – 1.5 is the useful range on brushed or hammered metal, where a map baked for a matte surface reads too soft under a sharp specular.

The range is clamped to 0 – 8; Z is left alone and the vector is renormalized, so even a large scale produces a legal normal rather than a broken one. This is the same knob as glTF’s normalTexture.scale and Unity’s _BumpScale, and it is passed through to the Unity runtime as well.

A normal map at a grazing angle packs many texels into one pixel, and a sharp specular lobe sampled at one point per pixel turns that into crawling white sparkle — the pixel is either inside the highlight or it is not, and which one it is changes as the camera moves a millimeter. The renderer widens the lobe to cover what the pixel actually spans, using the geometric estimator from Tokuyoshi and Kaplanyan (I3D 2019): it measures how far the shading normal turns across one pixel with the screen derivatives of that normal, treats that spread as a variance, and adds it to the GGX roughness.

The screen derivatives can only see the spread that is still in the texture, which is precisely the half that minification destroys: a distant surface hands the pixel one already-averaged texel, and the derivative of a smooth texel is small exactly where the normals it averaged were wildest. So the other half is measured at build time. The compiler records the length of each mip level’s mean normal (see Mipmapping; a BC5 normal map keeps it beside its blocks), the shader turns it into a Toksvig variance, and the two estimates add — disjoint halves of one spread — before the same doubling and the same specAaKappa ceiling. A normal map with no build-time chain contributes nothing here and the derivative estimate carries it alone.

It is a renderer setting rather than a material property, because it is a function of how the surface is sampled, not of what it is made of. The material’s authored roughness is untouched; the filter only ever raises the value the lobe is evaluated with, and only where the normal is actually turning fast under the pixel — a flat wall at close range gets nothing.

PreferenceDefaultWhat it does
client.render.specAatrueThe filter itself
client.render.specAaSigma0.25Screen-space filter width. Higher spreads more, and blurs more highlights that were not aliasing
client.render.specAaKappa0.18Ceiling on how much roughness one pixel may gain, so a silhouette cannot go matte
client.render.roughnessFloor0.045Lowest roughness any surface shades with, filter on or off

The floor is separate on purpose and applies even with the filter switched off: below it GGX collapses toward a delta that no amount of prefiltering can sample. 0.045 is the fp32 number; the 0.089 quoted in the literature is for half-precision pipelines, which this renderer is not.

Both knobs are honest defaults rather than safe ones. On authored content at default settings the filter is close to invisible in a still frame — where it earns its cost is in motion, on minified normal-mapped surfaces at distance, where it takes roughly 40% off the brightest crawling pixels.

A height map plus a heightScale makes a flat triangle shade as though it were displaced. Before any other channel is sampled, the fragment stage marches the view ray through the height field and moves the UV to wherever that ray first meets the surface — so brick courses occlude their own mortar at a grazing angle, and the parallax slides correctly as the camera moves. A normal map only tilts the lighting; this moves what you are looking at.

brick.dh-mat
{
"textures": {
"baseColor": "textures/brick-albedo.dh-tex",
"normal": "textures/brick-normal.dh-tex",
"height": "textures/brick-height.dh-tex"
},
"properties": {
"heightScale": 0.03
}
}

1.0 is the top of the height field and 0.0 the bottom, and heightReference says which of those values sits on the mesh. The plane is 1 - heightReference:

heightReferencePlaneRelief
0 (default)whitecarves entirely inward
0.5mid-graydisplaces both ways
1blackpushes entirely outward

The default is 0 for a reason. Parallax only shifts UVs — it cannot move the silhouette — so anything the field pushes above the triangle has nowhere to go and slides against the geometry as the camera moves. Carve-inward is the only artifact-free setting, and it is what a brick, a tile or a plaster facade wants anyway: the mortar recedes, the brick face stays put.

Raise it when the relief is genuinely two-sided and the surface is never seen near its silhouette — riveted panel, cobbles in a floor. 0.5 reproduces the old both-ways behavior exactly.

An unbound height channel samples neutral white, which sits on the plane at the default reference and is therefore exactly flat.

This is the one number that surprises people. The march works in texture space, so the depth is expressed in the same units as the UVs:

heightScale = worldDepth / worldUnitsPerUvUnit

A wall whose UVs tile once every 2 m, with brick relief 1 cm deep, wants heightScale = 0.01 / 2 = 0.005. The consequence is that changing uvScale changes the effective depth — tiling twice as densely halves the world-space relief, so a retiled material usually wants its scale re-authored.

Real authored values land between roughly 0.006 and 0.15, with most materials in 0.01 – 0.05. Values much past that read as smearing rather than depth, because the ray leaves the texel neighborhood the pixel belongs to.

heightScale defaults to 0, which is off: binding a height map and authoring no scale renders identically to binding no height map at all. That makes the feature a single-number A/B on any material.

The step count is adaptive: a surface viewed head-on or from far away resolves in few steps, a grazing close-up one takes up to 48. Past a few mip levels the height field is blurred toward its own average, so the offset ramps out and the surface falls back to plain normal mapping — a distant wall pays nothing for a feature it could not show anyway.

Where that ramp sits is a client preference, not a material property: it trades quality for framerate on the machine the frame is drawn on, the way a shadow distance does, so no material and no map may reach it. Both apply live, from the console or from Video → Parallax in the settings screen, where they read as Parallax fade starts at and Parallax fade range.

PreferenceDefaultDescription
client.render.parallaxFadeStart3Mip level the fade begins at
client.render.parallaxFadeLength5Mip levels the fade spans

The window is measured in mips, so each level is a doubling of distance: the defaults keep the march at full strength up close and take it out over a 32× range, which is long enough that the transition never reads as a line on the wall. Shorten parallaxFadeLength to claw back frames on a weak GPU, at the cost of that seam becoming visible; set it to 0 for a hard cutoff at the start mip.

The detail layer follows the marched surface point (through its own tiling rate), so detail lands on the displaced surface rather than the flat one — and it may carry a height field of its own, marched at its rate after the base’s. Baked lightmap UVs are never displaced — they are baked against the mesh, and moving the lookup would read another chart.

The march finds where the ray lands; it says nothing about whether that point can see the light. A parallax block with a shadow strength adds a second, shorter march — from the displaced point toward the light through the same field — and attenuates the light by how much relief stands in the way. Mortar goes dark under the lip of the brick above it, and the shadow moves with the sun.

brick.dh-mat
{
"textures": { "height": "textures/brick-height.dh-tex" },
"properties": { "heightScale": 0.03 },
"parallax": { "shadow": 0.8 }
}
FieldDefaultDescription
parallax.shadow0How dark the self-shadow is, 0–1. 0 skips the march entirely

The shadow is soft on purpose: a blocker’s weight falls with its distance from the shaded point and each tap up the ray reads a coarser mip, so a ridge far up the field casts a wide penumbra and a near one a hard edge — the Tatarchuk 2006 construction, with the mip ramp Drobot’s Two Worlds 2 implementation added. The first tap is jittered per pixel so the tap count reads as fine noise the temporal resolve removes rather than as bands.

Every light that reaches the surface is marched separately: the sun always, and point and spot lights while client.render.parallaxShadowLights is on. A light past its range contributes nothing and is skipped, so the cost scales with the lights that actually land on a pixel. The strength composes with the sun’s cascaded shadow map (they multiply) and rides the same fade window as the offset, so both leave the wall at the same distance.

The tap count is the client’s, like the fade window: Parallax shadow taps under Video → Parallax, or client.render.parallaxShadowTaps. 0 switches every material’s self-shadow off at once.

parallax folds child-wins as a whole block, like surface and detail: a material that authors any parallax at all replaces its parent’s. Unknown fields in the block are build errors.

The march moves where a pixel looks like it is, not where the depth buffer says it is. So anything that meets the surface — a crate half-sunk into cobbles, a pipe entering a brick wall — is cut off along the flat triangle the relief only pretends to be displaced from, and the seam is a dead straight line across the brickwork.

"depthWrite": true fixes that for one material: the fragment reports the depth the march actually found, so the intersection follows the mortar down and the relief occludes what stands in it.

cobbles.dh-mat
{
"textures": { "height": "textures/cobbles-height.dh-tex" },
"properties": { "heightScale": 0.15 },
"parallax": { "shadow": 0.6, "depthWrite": true }
}
FieldDefaultDescription
parallax.depthWritefalseWrite the marched depth instead of the triangle’s. Ignored without a height texture

It is off by default and worth turning on per material, not everywhere. A shader that writes gl_FragDepth gives up the free early-depth test for that draw, so every fragment of the material shades whether or not something later covers it. The declaration is conservative (layout(depth_less), the correct direction under the renderer’s reversed-Z), which keeps early-Z rejection — a fragment already behind the buffer is still thrown away — but not early-Z acceptance. Reach for it on the surfaces a person actually stands things on.

The write also respects everything the offset does: it is zero where the fade has taken the march out, it accumulates the detail layer’s own height at its own tiling rate, and it is clamped so a fragment can only ever sink away from the camera, never float toward it.

A client kill-switch turns it off everywhere at once, for measuring what it costs:

PreferenceDefaultDescription
client.render.parallaxDepthWritetrueWhether materials that ask for the marched depth get it

Two things deliberately keep the flat depth. The shadow pass rasterizes depth alone with no material bound, so a shadow is cast by the triangle; at the scale of a height field that is under the shadow map’s own filter width. The ambient-occlusion pass’s half-resolution depth prepass writes no marched depth either (its fragment stage, when a draw needs one, only discards), so AO is computed against the undisplaced surface — bounded by heightScale and smoothed away by its bilateral upsample.

"stochastic": true hides the repetition of a tiled texture. A ground or wall material tiling dozens of times across a surface reads as a grid from a distance — the eye finds the one bright pebble and then sees it every 2 m in both axes. With the flag on, the shader lays a triangular lattice over the UVs, samples the texture three times at a per-cell random offset, and blends the three by barycentric weight, so the same texel never lands in the same place twice.

gravel.dh-mat
{
"uvScale": [16, 16],
"stochastic": true,
"textures": {
"baseColor": "textures/gravel-albedo.dh-tex",
"normal": "textures/gravel-normal.dh-tex"
}
}

The flag is per material, not per channel: every bound channel shares one lattice and one set of offsets. That is the point — an albedo taken from one part of the texture and a normal taken from another would not describe the same surface.

Three fetches per channel instead of one, so a material with albedo, normal and metallic-roughness goes from three taps to nine. That is why it is opt-in per material rather than a global setting: flag the two ground materials that tile hard and leave the trim alone.

It composes with the rest of the material:

  • Height — the march samples the height field stochastically too, so the displacement lands on the part of the texture the albedo is actually showing.
  • Detail — the detail layer has its own stochastic switch, because it tiles at its own rate. A base layer that needs it does not imply a detail layer that does.
  • Lightmaps are never touched: they are baked against the mesh’s own parameterization, and moving that lookup would read another chart.

A texture with a deliberate pattern will break. Brick courses, floor tiles, planking, anything with a straight line that runs the width of the texture: the three offset copies no longer agree about where the line is, and the lattice edges show as a faint shimmering triangular seam. This wants stochastic content — gravel, dirt, sand, plaster, concrete, rust, noise. A rule of thumb: if you could rotate the texture 90° and not notice, it is a good candidate.

Two smaller caveats. The blend weights are sharpened rather than linear, because averaging three copies of a texture is flatter than the texture is; the sharpening keeps the contrast, but a material with very large features can still show a soft transition inside a cell. And the offsets are translation only — there is no per-cell rotation — so a texture with a strong directional grain keeps that direction everywhere.

The detail block is a second, complete channel set with its own uvScale, blended over the base channels in the shader. It exists because one uvScale is not enough: a surface routinely wants its albedo and roughness tiling at one rate and its normal and occlusion at another, and before detail the only way to express that was to author a second material and a second draw.

The real case it was built for — an elevator panel whose base normal and occlusion tile at 0.75 while its albedo and roughness tile at 0.5:

elevator-panels.dh-mat
{
"uvScale": [0.75, 0.75],
"textures": {
"normal": "textures/metal012-normal.dh-tex",
"occlusion": "textures/metal012-ao.dh-tex"
},
"properties": { "occlusionStrength": 0.735 },
"detail": {
"uvScale": [0.5, 0.5],
"textures": {
"baseColor": "textures/metal012-albedo.dh-tex",
"metallicRoughness": "textures/metal012-mr.dh-tex"
}
}
}
PropertyTypeRequiredDescription
texturesobjectnoDetail channel assignments (see below)
uvScale[u, v] or { "u", "v" }noThe detail layer’s own UV scale (default: [1, 1])
uvSetintnoWhich of the mesh’s UV sets the whole detail block samples, 0 – 3 (default: 0)
tintcolornosRGB tint multiplied into the sampled detail baseColor (default: white)
strengthobjectnoPer-channel strength in 0.0 – 1.0
blendobjectnoPer-channel blend mode
stochasticboolnoStochastic sampling for the detail channels, switched separately from the base layer (default: false)
vertexWeight"r" | "g" | "b" | "a"noWhich channel of the mesh’s COLOR_1 paints the layer on per vertex (default: none, the layer applies everywhere)
blendSoftness0.0 – 1.0noHow wide the edge is where the weight crosses the blendMask (default: 0.1)
blendMaskUvScale[u, v] or { "u", "v" }noThe scale the blendMask samples at (default: the block’s own uvScale)

uvSet belongs to the block, not to an individual detail texture — every detail channel tiles together at uvScale, so they sample together on one UV set too. Authoring uvSet inside one of the detail block’s own channel entries (rather than beside uvScale) is a compile error naming the texture, for the same reason a flat-authored detail block is: the value would silently apply to nothing. uvSet follows the same 0 – 3 range as a texture channel’s.

A pile-fiber material — a plush toy whose base normal carries the body’s seams on UV0, while a tileable pile-detail color and pile-normal sample a dedicated UV3 layout the body’s main unwrap has no correspondence to:

plush.dh-mat
{
"textures": {
"baseColor": "textures/plush-albedo.dh-tex",
"normal": { "texture": "textures/plush-seam-normal.dh-tex", "uvSet": 3 }
},
"properties": { "normalScale": 0.8 },
"detail": {
"uvSet": 3,
"textures": {
"baseColor": "textures/plush-pile.dh-tex",
"normal": "textures/plush-pile-normal.dh-tex"
},
"strength": { "baseColor": 0.3, "normal": 0.2 }
}
}

Whichever UV set a channel or the detail block samples, the tangent basis the normal is perturbed against always comes from the mesh’s own vertex tangents — uvSet only chooses where the map is read from, never what frame it is applied against.

The detail channel set is closed and is the data half plus albedo — there is no detail emissive:

ChannelKindSampledBlended into
baseColorcolorRGBAlbedo, after tint and the base baseColor
normaldataRGBBase tangent-space normal, whiteout-combined
metallicRoughnessdataG, BRoughness (G) and metallic (B) separately
occlusiondataRThe base occlusion sample, before occlusionStrength
heightdataRNothing — it is marched, not blended
blendMaskdataRNothing — it moves the edge between the layers

Data channels are held to exactly the same rule the base ones are: a dh.texture-defined sRGB texture on normal, metallicRoughness, occlusion or blendMask is a compile error; a raw image with no definition packages linear automatically instead.

strength and blend are keyed by channel role, not by texture channel, so metallic and roughness are tuned independently even though they share one packed map:

KeyDefault strengthDefault blend
baseColor1mul
normal1(strength only — normals always whiteout-combine)
metallic0mul
roughness1mul
occlusion1mul
height0(strength only — it is the field’s depth, see detail height)

A strength outside 0.0 – 1.0, an unknown key, or an unknown blend-mode name is a compile error. So is a flat-authored detail block — channels or strengths written beside uvScale instead of inside textures and strength. The diagnostic names the field and the object it belongs in:

// Wrong: parses, binds nothing, renders base only
"detail": { "normal": "/textures/scuffNormal.png", "normalStrength": 0.8 }
// Right
"detail": { "textures": { "normal": "/textures/scuffNormal.png" }, "strength": { "normal": 0.8 } }

metallic defaults to 0 — a detail map is texture, and texture rarely wants to change whether a surface is a conductor — so a packed detail metallicRoughness map changes roughness alone unless you ask for metallic too.

The detail layer can carry a height field of its own, marched at the detail layer’s tiling rate. This is the case a paving pattern laid over a concrete base wants: the concrete is the base layer, the tiles are the detail, and it is the tiles that have grooves.

sidewalk-tiles.dh-mat
{
"textures": { "baseColor": "textures/concrete-albedo.dh-tex" },
"uvScale": [0.25, 0.5],
"detail": {
"uvScale": [1, 1],
"textures": {
"normal": "textures/paving-normal.dh-tex",
"height": "textures/paving-height.dh-tex"
},
"strength": { "normal": 0.8, "height": 0.045 }
}
}

strength.height is the field’s depth in detail UV units — the detail layer’s own heightScale, measured against detail.uvScale rather than the base’s, so retiling the detail changes its relief the same way retiling the base changes the base’s. It defaults to 0, which is off: a bound detail height map with no strength marches nothing.

Both layers may march. The base field goes first; the detail field is then marched from the point the base march landed on, and its offset is carried back to the base channels too, so every channel of both layers samples one surface point. That is two marches in sequence rather than one joint march — exact whenever either field is flat, and within the depth a detail layer authors otherwise. The reference plane is the material’s one heightReference, shared by both fields, and the self-shadow marches both of them, each against its own map.

The height channel is data, held to the same linear rule as the other detail data channels.

The set and its ordinals mirror Mochie’s BlendColors, so a material ported from a VRChat Standard material blends the same way it did there. Blending happens on the sampled values in linear light, and strength (t) is folded into the detail operand rather than applied to the result afterwards. strength: 0 is an exact identity in every mode. With s = base and d = detail:

NameMath
adds + d·t
alphas − d·t
muls × mix(1, d, t) — the default
mulx2s × mix(1, d × 4.5948, t) (unity_ColorSpaceDouble taken into linear)
overlaymix(s, d < 0.5 ? 2·d·s : 1 − 2(1 − d)(1 − s), t) — the one mode whose t rides the result, because scaling d first walks toward black instead of toward s
screens + d·t − s·(d·t)
lerpmix(s, d, t)

The detail normal does not take a blend mode: strength lerps it toward flat and the result is whiteout-combined with the base normal (normalize(vec3(base.xy + detail.xy, base.z × detail.z))).

vertexWeight makes the detail block a two-layer material blended by vertex paint, the way a Source 2 csgo_environment_blend or a terrain shader blends its layers. Every strength in the block, height and occlusion included, is multiplied by the named channel of the mesh’s COLOR_1, so with lerp at strength 1 the detail layer replaces the base where the paint is 1 and is absent where it is 0:

{
"textures": { "baseColor": "textures/sand.dh-tex" },
"detail": {
"textures": { "baseColor": "textures/rock.dh-tex", "normal": "textures/rock-normal.dh-tex" },
"blend": { "baseColor": "lerp", "roughness": "lerp" },
"vertexWeight": "r"
}
}

A mesh with no COLOR_1 reads a weight of zero, so the material shows its base layer alone. A name outside r, g, b and a is a compile error. The matcap and envmap a material carries are not part of the layer and keep their own strengths. Only one layer is weighted this way; a three-layer blend needs a second detail block, which does not exist yet.

With only vertexWeight, the boundary between the layers is wherever the painted weight crosses a threshold, so it follows the mesh: a straight, soft gradient across each triangle. A blendMask texture bends it. The weight is compared against the mask per texel instead, which is how Source 2’s csgo_simple_2way_blend and the height blend of csgo_environment_blend break up the line between two ground layers:

{
"textures": { "baseColor": "textures/grass.dh-tex" },
"detail": {
"textures": { "baseColor": "textures/dirt.dh-tex", "blendMask": "textures/blend-noise.dh-tex" },
"blend": { "baseColor": "lerp" },
"vertexWeight": "g",
"blendSoftness": 0.1,
"blendMaskUvScale": [0.25, 0.25]
}
}

The layer’s weight at a pixel is smoothstep(m − s, m + s, w), with w the interpolated vertex weight, m the mask’s R at that pixel and s the blendSoftness. A bright mask texel resists the layer, so the edge reaches it later; a dark one lets it through early. The result is rescaled so a weight of exactly 0 is always the base alone and exactly 1 always the detail alone, whatever the mask says. A blendSoftness of 0 is a hard edge that follows the mask’s contour; 1 spreads the transition across the whole weight range, and the mask matters less the wider it gets.

  • Needs a vertexWeight. The mask is compared against the vertex weight; a blendMask with none is a compile error, since it would change nothing.
  • Sampling. The mask is read on the block’s own uvSet at blendMaskUvScale, or at the block’s uvScale when that is absent. A mask usually tiles at its own rate rather than the layer’s, which is what the second scale is for. It is sampled at the mesh’s own UVs: the parallax offset does not move it.
  • Cost. A material without a blendMask pays nothing: its pipeline variant compiles the comparison out, the descriptor binds a neutral texture that is never read, and its row in the layer table is byte for byte what it was.
  • Data. The mask is a data channel, held to the linear rule above.
PlatformBlend mask
Engine, Windows desktop✅
Engine, Linux and macOS desktop❔ the shader is shared; not yet run there
Engine, iOS❔ the shader is shared; not yet run on a device
Unity editor import (VRChat)❌ the mask, blendSoftness and blendMaskUvScale are not read; the layer takes whatever the importer does with the rest of the detail block
BONELAB, Garry’s Mod and the other game platforms❌ the mask is not carried

A blendMask needs a texture authored to the pattern. A height blend makes the two layers’ own relief the pattern, the way Source 2’s csgo_environment_blend does with g_tHeight1 and g_tHeight2: the higher of the two layers reaches the surface first, so a moss painted on over cobbles fills the cracks before it climbs the stones.

{
"textures": { "baseColor": "textures/cobbles.dh-tex", "height": "textures/cobbles-height.dh-tex" },
"detail": {
"textures": { "baseColor": "textures/moss.dh-tex", "height": "textures/moss-height.dh-tex" },
"blend": { "baseColor": "lerp" },
"vertexWeight": "g",
"heightBlend": true,
"blendSoftness": 0.1
}
}

The two heights are the material’s own height (the base layer’s, the same texture the parallax march reads) and the detail’s height; no second base-height slot exists. With h1 the base height and h2 the detail height, both read from R in [0, 1], the layer’s weight is the blend mask’s comparison with the threshold moved by the difference:

t = clamp(m − (h2 − h1), 0, 1)
weight = smoothstep(t − s, t + s, w) // rescaled so w = 0 gives 0 and w = 1 gives 1 exactly

w is the interpolated vertex weight and s the blendSoftness. Where the detail is taller than the base (h2 > h1) the threshold drops and the detail shows through at a lower paint; where the base is taller it rises. With equal heights the edge sits where it would with no height at all, and at w = 0.5 the taller layer is always the one showing more. A blendSoftness of 0 is a hard edge at the shifted threshold; the wider it gets the less the heights matter, as 1 spreads the transition across the whole weight range.

  • With a blendMask. m is the mask’s R at the pixel, so the mask sets the edge and the heights bend it. Without one, m is 0.5: the edge is halfway across the paint wherever the heights are equal.
  • Needs a vertexWeight and a detail height. Both missing are compile errors. A base with no height texture counts as 0.5 everywhere, so the detail’s relief alone steers the edge.
  • Sampling. Both heights are read at the mesh’s own UV0, each at its layer’s scale (uvScale for the base, the detail’s uvScale for the detail), before the parallax march and unmoved by UV animation. A height blend does not need heightScale or strength.height: the heights decide the edge even with the march off.
  • Cost. It shares the blend mask’s pipeline variant and its blendSoftness lane; a material with neither pays nothing, and its row in the layer table is byte for byte what it was.
PlatformHeight blend
Engine, Windows desktop✅
Engine, Linux and macOS desktop❔ the shader is shared; not yet run there
Engine, iOS❔ the shader is shared; not yet run on a device
Unity editor import (VRChat)❌ heightBlend is not read
BONELAB, Garry’s Mod and the other game platforms❌ not carried

detail folds child-wins as a whole block, like surface: a material that authors any detail at all replaces its parent’s block entirely rather than merging field by field. Half-inheriting a detail layer — a child’s uvScale over a parent’s textures — reads as a bug far more often than as an intent. A material that authors no detail inherits its parent’s unchanged.

A detail mask (a texture gating where the layer applies) and the packed detail workflow (one texture carrying several detail channels) are deferred.

detail on a spawned dh.object — an avatar included — works. The detail parameters live in a table the scene builds when a map loads, but that table grows: a streamed object appends its rows into it and the scene rebuilds the buffer, so a spawned object and a map surface wearing the same dh.material shade identically. The one thing to watch is the UV set: uvSet names one of the mesh’s TEXCOORD_n attributes, and a set the mesh never authored reads (0, 0) at every vertex — the whole surface samples one texel of the detail texture, which looks like the layer doing nothing at all. The engine says so at WRN when it happens, naming the object and the material.

A material’s decals list lays up to four colors over its base albedo, each placed on a UV set of its own and shaped by one channel of a mask. This is a material layer, not a projected decal — nothing is stamped into the world and no second draw happens. The placement is in UV space, so a decal follows the surface it was authored onto however the mesh moves, which is what makes it the right shape for a logo on a jacket, a number on a door, or a blush over one island of an avatar atlas.

jacket.dh-mat
{
"textures": { "baseColor": "textures/jacket-albedo.dh-tex" },
"decals": [
{
"texture": "textures/logo.dh-tex",
"uvSet": 1,
"position": [0.12, 0.30],
"scale": [0.4, 0.4],
"rotation": -8
},
{
"tint": "#7A2F2F",
"blend": "multiply",
"blendAlpha": 0.6,
"mask": "textures/jacket-masks.dh-tex",
"maskChannel": "g"
}
]
}

The list shades in order: entry 0 goes over the base, entry 1 goes over the result, and so on. Four is the ceiling because the shader keeps one program and loops to a compile-time bound rather than building a permutation per decal count. A material with none does not even carry the loop: its draws record with a shader variant that compiled the decal block out.

PropertyTypeRequiredDescription
texturestringnoThe decal’s color texture, decoded on sample like the base albedo. Omit it for a flat tint shaped entirely by the mask
uvSetintnoWhich of the mesh’s UV sets places the decal, 0 – 3 (default: 0)
tintcolornosRGB color multiplied into the sample (default: white). Its alpha shapes the decal’s own coverage
blendstringnoHow the decal combines with what is under it (see below, default: normal)
blendAlphafloatnoThe layer’s overall weight, 0.0 – 1.0 (default: 1)
maskstringnoA data texture whose maskChannel multiplies the decal’s alpha
maskChannelstringnor, g, b or a (default: r)
maskUvSetintnoWhich UV set samples the mask, 0 – 3 (default: 0)
position[u, v]noOffset in UV units (default: the origin)
scale[u, v]noSize in UV units (default: [1, 1])
rotationfloatnoDegrees about the decal’s own center (default: 0)

maskUvSet is separate from uvSet on purpose: a shared mask atlas is laid out on the set that carries the whole body, while the decal it shapes is placed on another. One mask texture therefore serves four decals through its four channels, which is the arrangement the ported avatar materials use.

An entry with no texture and no tint shades nothing, and the compiler says so as a warning rather than letting it sit in the loop. An unknown member, an unknown blend or maskChannel, a uvSet out of range, a blendAlpha outside 0.0 – 1.0, or a fifth entry are all compile errors.

The decals blend table is Poiyomi’s, taken from the avatar shader this was ported from, and it is not the detail layer’s — the two disagree on nearly every name, so they are separate vocabularies rather than one shared list. Blending happens on the sampled values in linear light, and the result is saturated. With s = surface and d = decal:

NameMath
normald — the default
darkenmin(s, d)
multiplys × d
colorBurn1 − min(1, (1 − s) / d)
lightenmax(s, d)
screens + d − s·d
adds + d
overlays ≤ 0.5 ? 2·s·d : 1 − 2(1 − s)(1 − d)
subtracts − d

The blended color is then mixed back over the surface by decalAlpha × blendAlpha, where decalAlpha is the sampled alpha times tint’s alpha times the mask channel. So blendAlpha weights the whole layer and tint’s alpha weights the decal’s own coverage — a distinction that matters when a mask already shapes the edges and you want the interior lighter.

PlatformDecals
DigitalHeaven engine✅
BONELAB🟡 through the mod’s own DH/LitMAS Decals shader, UV sets 0 to 2; untested in game
Other Unity games❌ the textures load and are recorded (MaterialSemantics.Decals), but no game shader has a slot to draw them

decals folds entry by entry, by position — not child-wins as a whole block the way detail and surface do. A material that authors no decals inherits its parent’s list unchanged. A material that authors any states how many the material has, so a variant can drop a decal as well as retune one; entry i then reads through to entry i of the base for every member it leaves unstated. That is what lets a variant restate a blendAlpha without restating the texture, the mask and the placement that go with it.

A decal’s own normal, roughness or emissive contribution is deferred — a decal lays over albedo alone, so a sticker does not yet change how the surface under it reflects. So is a decal on a water material, and per-decal stochastic sampling.

A material’s matcap block shades it with a sphere of light addressed by the view-space normal: the surface normal is rotated into the camera’s frame and its xy becomes the UV, so the texture reads as a ball lit once and re-lit from nowhere afterward. It composes with the light already on the surface, after direct and indirect lighting and before the tonemap, which is what separates it from a decal — a decal changes albedo, a matcap changes what the albedo came out as.

taidum.dh-mat
{
"textures": { "baseColor": "textures/taidum-body.dh-tex" },
"matcap": {
"texture": "textures/plush-matcap.dh-tex",
"mask": "textures/taidum-readability.dh-tex",
"maskChannel": "r",
"blend": "add",
"strength": 0.6,
"tint": "#FFF2E6"
}
}

The sphere has no UVs of its own — the normal addresses it — so nothing places it and nothing tiles it. The mask samples its own UV set, maskUvSet, and that is the whole point of it: its channel scales strength per texel, so the matcap can be given to the parts of a surface that need it and withheld from the parts that do not.

PropertyTypeRequiredDescription
texturestringyesThe matcap sphere, a color texture. Without it the block shades nothing and the compiler warns
maskstringnoA data texture whose maskChannel scales strength per texel (default: unmasked, which is full strength everywhere)
maskChannelstringnor, g, b or a (default: r)
maskUvSetintnoWhich UV set samples the mask, 0 – 3 (default: 0)
blendstringnoHow the matcap combines with the lit color (see below, default: add)
strengthfloatnoThe matcap’s weight, 0.0 – 4.0 (default: 1)
lightingstringnolit or unlit: whether the matcap goes dark with its surface (see below, default: lit)
tintcolornosRGB color multiplied into the sampled matcap (default: white)

maskUvSet exists because a readability mask is routinely rasterized onto a UV layer of its own, distinct from whatever set the base channels sample — the plush Taidum’s matcap sphere rides UV0 while its readability mask reads UV3.

strength goes above one on purpose: an added matcap is routinely pushed past unity, and the mask is what pulls it back where it would be too much.

This is a third vocabulary — not the decals’ and not the detail layer’s. A matcap composes with light, not with albedo, so it shares neither their names nor their numbering, and a name from either of those lists is a compile error rather than a quiet fallback. With lit = the surface’s lit color, m = the sampled matcap times tint (times the light share for a lit matcap), and w = strength times the mask channel:

NameMath
addlit + m·w — the rim and sheen look, and the default
replacemix(lit, m, clamp(w, 0, 1)) — the lighting-replacement look
multiplymix(lit, lit·m, clamp(w, 0, 1)) — the shading-tint look

replace and multiply both lerp so that a zero-masked texel is untouched either way: a mask is a statement about where the matcap applies, and it has to mean the same thing in all three modes.

A matcap is lit by default: before it composes, m is scaled by the light the pixel actually receives, so a surface in pitch dark shows no matcap at all. The light is what a white surface at that point would receive — the sun and every analytic light with their shadows and the half-Lambert wrap, the lightmap, the ambient fill or light probes with occlusion and screen-space occlusion — with no albedo, no metal and no reflections in it. The share is

share = min(light / client.render.matcapFullLight, 1) per channel

so a matcap in at least client.render.matcapFullLight of light composes exactly as an unlit one, less light dims it in proportion (and tints it with the light’s color), and none hides it. The default, 0.25, is a quarter of a default-intensity sun: in the default map’s daylight almost every point of a matcap receives that much, so a normally lit scene keeps the look it had. Set it to 1 to dim a matcap on its shadowed side exactly as the surface under it dims, which is what Poiyomi does.

multiply is lit either way — it multiplies light that is already there. Emission is added after the matcap and is never touched by any of this.

"lighting": "unlit" restores the old behavior: the sphere shades the same in any light, like emission. It is for a matcap that is meant to glow.

Poiyomi composes its Replace, Multiply, Add, Screen and Mixed matcaps into the base color before lighting, so every one of them goes dark with the surface. Only two of its controls bypass the light: Unlit Add (_MatcapAddToLight) and Emission Strength (_MatcapEmissionStrength). That is why lighting is a mode and not a fraction: no Poiyomi property carries a partial answer.

PoiyomiHere
Replace, Add or Multiply weightblend with that weight as strength, lighting: "lit" (the default)
Unlit Addblend: "add", lighting: "unlit"
Emission Strengthblend: "add", lighting: "unlit"
Hide in Shadow (_MatcapLightMask)Not carried. It fades the matcap by Poiyomi’s own shadow ramp on top of the lighting, and there is no ramp here to read

The mask exists for dark surfaces. An almost-black albedo reflects almost nothing, so no amount of light makes it legible — and a minimum-light control does not fix that either, because it raises the lighting contribution, which an albedo near zero then multiplies away. A matcap is added after the albedo multiply, so it is the one lever that puts light on a surface that cannot reflect any.

That makes the mask an anatomical map rather than an ambient-occlusion one, and it is often the inverse of one: a nose, an ear or a muzzle gets more support than the crease beside it, because those are the forms that have to read at distance. Author it that way — by where the shape needs to be seen, not by where light would naturally fall.

matcap folds child-wins as a whole block, like detail and surface and unlike decals. A variant that authors a matcap at all states the entire block; one that authors none inherits its parent’s unchanged. So a variant retuning strength restates the texture and the mask with it — which is deliberate, since a matcap and the mask that shapes it are authored as one thing.

A second matcap layer is deferred, and so is a matcap on a water material, and so is Poiyomi’s Hide in Shadow. In Unity games the block is not drawn at all: the Unity runtime puts a material on the Standard or URP/HDRP Lit shader, or the host game’s own, and none of them has a matcap slot, so a matcap there is neither lit nor unlit but absent. The rim is not a block of its own: a rim is a matcap whose sphere is bright at the edge and dark at the center, which is how the avatar shaders this was ported from author one anyway.

A material’s envmap block is Source’s $envmap: the reflection probe bound to the surface is added to its lit color under a tint and a mask, the way LightmappedGeneric and VertexLitGeneric add it, instead of being reached through the physically based specular a metallic and a roughness describe. A Source window reflects its cubemap at the strength its $envmaptint says; a dielectric reflects about four percent of it head-on, which is why imported glass reads dark without this block.

building_template006b.dh-mat
{
"textures": { "baseColor": "textures/building_template006b.dh-tex" },
"envmap": {
"tint": [0.6, 0.6, 0.6],
"mask": { "from": "baseAlpha" },
"fresnel": 1
}
}

The term is the SDK’s, in the SDK’s order (lightmappedgeneric_ps2_3_x.h, Source SDK 2013): the probe along the plain mirror direction, times the mask and the tint; pushed toward its own square by contrast; pulled toward its Rec. 601 gray by saturation; scaled by fresnel + (1 - fresnel)(1 - n·v)⁵; and dimmed by the diffuse light on the surface by lightScale. It is added after occlusion, as Source adds specularLighting, and a material that authors the block takes nothing from the engine’s own probe specular, since the two answer one question.

PropertyTypeRequiredDescription
tintcolorno$envmaptint, sRGB, multiplied into the reflection (default: white)
maskobjectnoWhere the per-texel strength comes from (default: unmasked)
mask.texturestringfor texture$envmapmask: a data texture whose RGB scales the reflection per channel, at the material’s own UVs
mask.fromstringnotexture, baseAlpha ($basealphaenvmapmask: one minus the base color’s alpha, as both SDK shaders read it) or normalAlpha ($normalmapalphaenvmapmask: the normal map’s alpha) (default: texture)
fresnelfloatno$fresnelreflection: the strength head-on, rising to full at grazing angles, 0 – 1 (default: 1, no falloff)
contrastfloatno$envmapcontrast, 0 – 1 (default: 0)
saturationfloatno$envmapsaturation, 0 – 1 (default: 1)
lightScalefloatno$envmaplightscale: lerp(c, c × saturate(light), lightScale), 0 – 1 (default: 0)

The field set is closed, every scalar outside 0 – 1 is a build error, a texture mask without a texture is a build error, and an alpha mask on a material that binds no such channel is a warning. The block folds whole through inherits, as the matcap does: a variant that states one field restates the reflection.

Which probe a surface reads is a probe box holding the pixel where one does, and elsewhere the probe its draw is bound to: the one its primitive or its instance names, else the nearest probe with no box. See reflection probes.

PlatformEnvmap
Engine, Windows desktop✅
Engine, Linux and macOS desktop❔
Engine, iOS❔ the shader is shared; not yet run on a device
Unity, BONELAB, Garry’s Mod and the other game platforms❌ the material’s metallic and roughness stand in

A material with a water block is a water surface, and the mesh it is applied to is the boundary of the body — there is no separate water type and no body primitive. The engine draws such a mesh in its own pass after the opaque scene and the sky, as a dielectric interface (Fresnel at the reflectance the body’s index of refraction gives, 0.02 for fresh water) over an absorbing medium: what lies under the surface is read back from the finished frame, refracted by the surface slope, and attenuated per channel over the distance the view ray travels through the water. The material’s base channels are not sampled.

harbor.dh-mat
{
"name": "Harbor",
"water": {
"preset": "ocean",
"transmittanceColor": [0.45, 0.78, 0.88],
"atDistance": 4.0
}
}
PropertyTypeRequiredDescription
presetstringnoOne of ocean, lake, pool, river, swamp, stylized, baltic, oil, mud, quicksand, silt (default: lake)
transmittanceColorcolornoThe color the water transmits after atDistance meters, authored in sRGB (default: the preset’s)
atDistancefloatnoThe distance in meters at which transmittanceColor is reached; must be positive (default: the preset’s)
scatterfloatnoHow much of the light crossing the body is handed back rather than swallowed, 0 to 1; zero is clear water, one is a sheet of silt (default: the preset’s)
scatterColorcolornoWhat the suspended matter itself is colored, in sRGB, before the sky lights it; ignored where scatter is zero (default: the preset’s)
fogColorcolornoThe color the water settles to far from the eye, in sRGB, used exactly as stated with no sun or sky lighting it. Stated, it replaces the lit haze (scatter, scatterColor and the light) as the far color, from above the surface and from inside the body alike; transmittanceColor and atDistance still decide how quickly the water reaches it. Unset, nothing changes. A channel outside [0, 1] warns. This is Source’s $fogcolor (default: none)
waveScalefloatnoA multiplier over the preset’s wave amplitudes; zero is a calm surface with no swell that still ripples by its chop, negative means 1 (default: 1)
chopScalefloatnoA multiplier over the preset’s normal-only chop; zero removes it, negative means 1 (default: 1)
normalStrengthfloatnoHow hard the material’s own normal texture tilts the water, read flat in the plane of the surface; zero (the default) ignores the texture and the ripples are the procedural chop alone. See Authored ripples
normalMixfloatnoThe share of the ripples taken from the normal texture, 0 (the chop alone) to 1 (the texture alone, as Source water reads); outside that range falls back to 0.5
normalScalefloatnoHow many meters one tile of the normal texture spans, at least 0.01 (default: 2)
normalScale2floatnoReads the same texture a second time at this many meters a tile, which hides the repeat; zero (the default) reads it once
normalScroll2[u, v]noThe second read’s drift in tiles per second; the first read drifts at the material’s own uvScroll
refractionScalefloatnoA multiplier on how far the ripples shove what is seen through the surface; zero shows the bed steady, negative is a build error (default: 1)
reflectionScalefloatnoA multiplier on how far the ripples warp the reflection, never on how much is reflected; zero is a mirror of a flat sheet (default: 1)
foamWidthMetersfloatnoHow far the shoreline foam reaches up the bank, in horizontal meters; zero removes the band, negative is a build error (default: the preset’s)
foamColorcolornoThe foam’s sRGB reflectance, lit by the sun and sky like any other surface (default: the preset’s)
chopWavelengthMetersfloatnoThe longest crossing chop layer’s size in meters; must be positive (default: the preset’s)
flowobjectnoFlow — a current in the horizontal plane (default: the preset’s, which is none for everything but river)
densityfloatnoThe liquid’s mass per cubic meter in kg/m³; must be positive. It decides buoyancy and, unless ior is stated, the index of refraction (default: the preset’s)
viscosityfloatnoHow thick the liquid is to a swimmer, as a multiple of water, within [0.2, 50]: the world’s swim drag is multiplied by it and every swim speed (the stroke, the sprint, the vertical stroke, the buoyant drift) divided by it. Every preset is 1 but mud (4) and quicksand (8) (default: the preset’s)
iorfloatnoThe liquid’s index of refraction over air; must be greater than 1 (default: derived from the preset’s density)
dispersionfloatnoHow far the liquid spreads a ray by wavelength, 0 to 1; 0 removes the fringing (default: derived from ior)
causticStrengthfloatnoA multiplier over the caustics the body casts; 0 removes them, negative is a build error (default: 1)
totalInternalReflectionboolnoWhether the underside is physical. true draws Snell’s window overhead and, past its edge, a mirror of the water’s own bed and banks; false draws no mirror from below, and the whole underside shows the world above, refracted and wobbled, as Source’s water does (default: true)
thinFilmThicknessfloatnoHow deep the surface film is in nanometers, 0 to 25000 — see Thin film (default: 450)
thinFilmIorfloatnoThe film’s own index of refraction over air, greater than 1 and at most 3 (default: 1.3330)
thinFilmWeightfloatnoHow much of the surface reflectance the film accounts for, 0 to 1; 0 is no film at all (default: the preset’s, which is 0 for everything but oil)
thinFilmVariationfloatnoHow far the film’s thickness swings across the surface, in nanometers, 0 to 5000; 0 is one flat tint per angle (default: the preset’s)
thinFilmSheenfloatnoA multiplier over the film’s chroma alone, 0 to 4; 1 is the physical interference term (default: the preset’s)

The block is closed: an unknown field, an unknown preset, a non-positive atDistance, a negative foamWidthMeters, a non-positive chopWavelengthMeters, a non-positive density, a viscosity outside [0.2, 50], an ior at or below 1, a scatter outside [0, 1], a dispersion outside [0, 1], a negative causticStrength, a thinFilmThickness outside [0, 25000], a thinFilmIor at or below 1 or above 3, a thinFilmWeight outside [0, 1], a thinFilmVariation outside [0, 5000] or a thinFilmSheen outside [0, 4] is a build error. A transmittanceColor or foamColor channel outside [0, 1], or a black transmittance, is a build warning — the channel is clamped before the inversion, so the water will not look like the color authored.

The water block is also the one block that inherits field by field. Elsewhere a child’s block replaces the parent’s whole; here a variant may add a flow to a body without restating its preset, which is exactly what the core …Flowing materials do.

Ocean, pond and pool are different physics, not different tints. Open water absorbs red hardest, so a deep ocean floor goes blue; pond and river water is dominated by dissolved organics that absorb blue hardest and transmit green-yellow; a pool is near-clear over a bright painted bottom, and its blue is the paint. Each preset is a (transmittanceColor, atDistance) pair — “at this many meters, the water looks like this color” — and an author picks the preset closest to the body and overrides only those two fields.

Not every preset is water. The set spans two axes a body can move on — how much salt it carries, and how much it is clouded by — and the far ends of both are liquids nobody would call water: oil floats on it, mud and quicksand are heavy enough to stand a swimmer up. They are here rather than in a type of their own because everything that makes them different is already a field on this block.

A preset is a set of field values, not a value, so in a file it sits UNDER every field the material states: it seeds every member nobody wrote down, and a member somebody wrote down is the member. A file that states "preset": "ocean" beside a "waveScale": 0.25 keeps the 0.25, and an inherits level that adds a flow keeps its parent’s preset.

Live, one more rule makes the dropdown reliable: choosing a preset with material.set drops the live overrides of the fields that preset seeds — transmittanceColor, atDistance, scatter, scatterColor, flow, foamWidthMeters, foamColor, chopWavelengthMeters, density, viscosity, ior, dispersion, thinFilmWeight, thinFilmVariation and thinFilmSheen — in the same edit, so the material looks exactly like the preset picked no matter which numbers were touched before it. waveScale, chopScale, causticStrength, thinFilmThickness and thinFilmIor fall back to a constant rather than to the preset, so they are the person’s own knobs and survive the swap. Tuning a number never pins the preset, picking a preset never leaves a number of a different one behind, and edits made after the pick are overrides on top of it. It is one undo entry either way: undoing a preset change restores the previous preset and the overrides it dropped.

PresetTransmits (sRGB)AtHazeHaze color (sRGB)
ocean[0.45, 0.78, 0.88]4 m0—
lake[0.55, 0.62, 0.45]2 m0—
pool[0.80, 0.92, 0.96]6 m0—
river[0.50, 0.50, 0.35]1 m0—
swamp[0.35, 0.33, 0.20]0.5 m0—
stylized[0.30, 0.75, 0.85]3 m0—
baltic[0.55, 0.58, 0.24]3 m0—
oil[0.05, 0.04, 0.03]0.25 m0—
mud[0.45, 0.18, 0.02]0.05 m0.95[0.40, 0.26, 0.13]
quicksand[0.62, 0.42, 0.10]0.04 m0.95[0.68, 0.56, 0.36]
silt[0.62, 0.52, 0.32]2.5 m0.6[0.72, 0.58, 0.36]

A cloudy body is cloudy for one of two reasons, and they do not look alike. Dissolved color — the tea-colored organics a river carries into a lake or a brackish sea — only ever absorbs: it kills a band (blue first, almost always) and the body gets darker and more colored the deeper you look. Suspended matter — silt, clay, saturated sand — scatters: it hands light back toward the eye, so the body gets brighter, paler and less saturated with depth, and a strong one is a lit sheet rather than a hole.

transmittanceColor and atDistance are the absorbing half and were always here. scatter and scatterColor are the scattering half: scatter is the fraction handed back and scatterColor is what the suspended matter is, lit by the sun and sky like any other surface rather than glowing on its own. Both are needed because neither can fake the other — turning absorption up to make a body murky makes it dark, which is the swamp, and that is a different water from mud. baltic is the absorbing kind at ocean scale, hazing at zero so that nothing but its transmittance makes it murky; mud and quicksand are the scattering kind, opaque within centimeters and pale rather than dark. silt sits between: enough scatter to brighten and veil a river bed, not so much that it opens within centimeters — the bed still shows a couple of meters down.

fogColor is the third way to say what the far water looks like, and the only one that is not lit. The haze fields describe sediment and let the scene’s light decide the result, so an overcast sky darkens it and night blacks it out. fogColor states the pixel instead: the far water is that color in any light. It exists because Source’s $fogcolor is exactly that, the color deep water settles to, and a lit reflectance cannot reproduce it without the importer guessing the scene’s light.

The extinction the shader actually uses is derived at load — -ln(color) / distance per channel, in linear light — and is never authored directly. Neither is the reflectance the interface shows head-on: that is the body’s index of refraction, and the index is what an author moves. Fresnel power, a raw depth threshold, a tiling scale and a wave count are not fields either: each of those is a slider that compensates for a bug elsewhere, and none exists.

A preset also carries the body’s density in kilograms per cubic meter, because what a water carries is the one water number a player feels. It is what the buoyancy samplers weigh a floating prop against, so the same crate rides higher in some of these than in others, and like every other number here it is an override the block may state: "density": 1005 is a body of this kind that happens to be brackish. The same number decides what the water does to a swimmer: it is weighed against world.water.playerDensity (1000, the reference fresh water), and a submerged player gets g · (1 − waterDensity / playerDensity), so denser water lifts, thinner water sinks and equal water hangs.

PresetDensityA submerged swimmer
ocean1025lifts — salt water carries you up; a diver swims down
lake1000hangs — the reference fresh water, zero gravity
pool1000hangs
river1000hangs — a current carries you along, not up
swamp960sinks — thin, dead water that will not hold you
stylized1060lifts — buoyant by art direction, like the color
baltic1005lifts a little — seven parts per thousand of salt, a fifth of the ocean’s
oil900sinks, hard — a body put into it goes straight to the bottom
mud1700lifts — nothing sinks in it; a crate rides on top, and at viscosity 4 every stroke is a quarter as fast
quicksand2000lifts at −g — twice a swimmer’s own density, so the law alone stands a person at waist height and will not let them under, and at viscosity 8 it barely lets them move
silt1010lifts a little — a sandbar’s worth of suspended load over fresh water

Author a denser water and a diver has to swim down; there is no separate player knob. See Physics props and Swimming and buoyancy.

The density decides one more thing: how the body bends light. The index of refraction is derived from the resolved density — the authored one where a material states it, the preset’s otherwise. It is 1.3330 + 0.00026 · (density − 1000), a fresh-to-seawater fit rather than a law, and everything optical flows from that single number. It is the eta the shader refracts with, so it sets how wide the Snell window opens over a swimmer’s head; it sets the head-on reflectance the interface shows, ((n − 1) / (n + 1))²; and it seeds the dispersion below.

PresetDensityIndexDispersionHead-on reflectance
ocean10251.33950.1030.0207
lake10001.33300.1000.0200
pool10001.33300.1000.0200
river10001.33300.1000.0200
swamp9601.32260.0960.0189
stylized10601.34860.1080.0216
baltic10051.33430.1010.0201
oil9001.4700 (stated)0.1830.0355
mud17001.51500.2220.0412
quicksand20001.59300.3140.0513
silt10101.33560.1010.0206

oil is the one preset that states its ior rather than deriving it: it is not water, so the fresh-to-seawater fit has nothing to say about it, and 1.47 is the crude-oil figure. The Snell window over a swimmer’s head closes from water’s 97° to about 86°, and the surface reflects nearly twice as hard as fresh water does. The two sediment bodies derive theirs like everyone else, which is why a 4–5% surface reflection sits over an otherwise opaque sheet — wet mud is glossy, and that gloss is the index, not a knob.

Three fields override those inputs, and each defaults to exactly the value derived above, so a body that states none of them and a body that states the physical ones are the same body.

ior is for being a different liquid. “My pool is ethanol” (1.361), “my pit is glycerol” (1.474): state the index and the window, the reflectance and the fringing all move together, because they are all that one number seen from different sides.

dispersion and causticStrength are for lying on purpose. Water’s Abbe number is about 55, which spreads a ray so little that the fringing is nearly invisible — the default the correlation V = 178 − 92 · n produces. An art direction that wants a rainbow at the edge of a caustic raises dispersion; one whose artists rejected the physical caustics scales them with causticStrength. Neither is a claim about a liquid, and that is the point of keeping them apart from ior.

A slick is not a tint. A lamina a few hundred nanometers deep makes the light reflected off its top face interfere with the light reflected off its bottom face, so which wavelengths survive depends on how far the second path went — on the film’s depth, its index and the angle it is looked at. That is the whole of it, and it is why an oil sheen’s color walks as the eye moves and a painted-on rainbow’s does not.

The engine evaluates the Airy summation spectrally, in the Belcour–Barla analytic form — no lookup table, no authored hue ramp — and mixes the answer against the body’s ordinary Fresnel by thinFilmWeight. The shared include iridescence.glsl owns it, so the water and any other surface that grows a film are the same math, and a CPU mirror in Iridescence.cs is held against it by test.

Four numbers author a film, and only the first is the physics of the lamina itself:

  • thinFilmThickness is the depth in nanometers. Near-grazing the hue walks a full turn about every 250 nm, which is what sets the swing below.
  • thinFilmIor is the film’s own index. It is read against the body’s ior on the far side, and the phase flip that makes a vanishing film go dark rather than white is derived from which of the two is larger — never hardcoded.
  • thinFilmVariation is how far the depth swings across the surface. Zero is an even film, which carries one flat tint per angle: a coated lens, not a slick. The swing is laid down by the water’s own noise, read in a frame aligned to the current, squashed across it and folded back through itself, so the bands marble into streaks that run downstream instead of blobbing.
  • thinFilmSheen multiplies the film’s chroma and nothing else: the reflectance is split into Rec. 709 luminance and chroma, only the chroma is scaled, and the two are recombined. Scaling the reflectance instead would only brighten the sheen and wash it out. One is the physical term; oil ships 3, which is what brings the bands up to a photograph of a real slick.

Every preset but oil carries thinFilmWeight 0, and at zero the shader takes the very same path it took before films existed — so no other body’s pixels move.

How wide the streaks are drawn is a scene question rather than a material one, so it is the client preference client.render.waterFilmScale (default 1) rather than a field: under 1 tightens the marbling toward finger-width, over 1 opens it out. Like client.render.waterThinFilm, it is read when a water row is built, so it lands on the next map load or the next material.set, not on the next frame.

The surface moves. Each preset carries a small Gerstner spectrum — up to four waves, longest first, each a direction in the XZ plane, an amplitude and a wavelength — plus the steepness they share and a multiplier over deep-water dispersion (ω = sqrt(g · k)). The sum displaces the mesh in the vertex stage and is evaluated again per fragment for the slope the refraction and the Fresnel read, so the silhouette and the shading are the same surface. It is also evaluated on the CPU by the same function, so anything that has to agree with the water — buoyancy, a boat, a swimmer — agrees with what is on screen.

A spectrum is only a spectrum if its lanes disagree. Four waves that travel the same line and divide evenly into each other are one wave with a beat, and they read as evenly spaced bands marching in one direction — which is what a still pond looked like before this. So every preset spreads its four headings, steps its wavelengths down by the deliberately non-integer ratios 1 : 0.618 : 0.370 : 0.233, and gives each lane a phase of its own. Tests hold all three: no wavelength ratio within 1% of a whole number, no two phases within 0.1 rad, and the lane spread obeys the rule below.

A lane is a line, not an arrow: antiparallel lays the same crest lines as parallel, so the two count as the same lane. An even fan is the trap the presets fell into first — four headings 45° apart are two perpendicular pairs, a square lattice, and a square lattice reads as herringbone rather than as sea. So the rule, held by WaterLanes.MinimumLineGapDegrees and asserted over every preset in both the Core and the Engine tests, is that no two lanes may be within that gap of parallel or of square, and no lane may run within that gap of a world axis — an axis-aligned lane prints its crests straight down a texture grid and a tessellation grid at once. The same rule governs the chop layers.

PresetLane lines (degrees, longest wave first)
ocean15 / 55 / 120 / 160
lake15 / 40 / 75 / 145
pool25 / 60 / 130 / 165
river15 / 50 / 120 / 155
stylized20 / 60 / 125 / 165
baltic30 / 67 / 105 / 132
oil15 / 55 / 130
mud20 / 60
quicksand30 / 105
silt37 / 74 / 112 / 139

swamp has no waves and so no lanes. The four thick bodies carry fewer than four: a viscous liquid does not hold a spectrum, and what they need the lanes for is slope, not height. A body with no waves hands the sky one constant mirror direction, and a reflection that never changes is paint rather than a liquid — which is why mud and quicksand carry a long slow heave that never breaks even though nothing about their depth would ever show.

Below the spectrum is the chop: four crossing layers of pure slope, an order of magnitude shorter than the shortest wave. The chop perturbs the normal only. It never displaces a vertex and never enters the Jacobian, because its wavelength is far under any tessellation a load-time subdivision could pay for — and that is the point: a pond with amplitudes measured in millimeters still has to shimmer, and the shimmer is a normal, not a shape. Each preset sets its peak slope and its longest layer; even swamp, which has no waves at all, keeps the faintest chop, because a film of water is never mirror-flat.

Amplitude ramps to zero over shoreFade meters of the body’s boundary. That is both true (waves flatten as they shoal) and necessary: a displaced mesh that did not pin at its rim would tear away from the bank it sits in.

Foam has two sources and one mask. Shoreline foam is the water’s depth over whatever lies behind it, measured from the displaced fragment so the wash rides the swell up the beach and back down instead of standing still as a contour line; its foamWidth is a horizontal distance in meters, converted to a depth by the slope of the receiving surface, so a rock does not get a fat collar while the beach beside it gets a hairline. Crest foam comes from the Jacobian of the horizontal displacement — the measure of the surface gathering itself into a crest — so it is born on the crest and travels with it. Both are broken up by two scrolling noise fields, one large and slow and one small and fast, keyed to world XZ and never to the screen.

PresetWavesLongestSteepnessPhaseChopShore fadeFoam widthFlow
ocean40.42 m at 26 m0.851.000.22 at 1.6 m3.5 m1.6 mnone
lake40.085 m at 7.4 m0.501.000.14 at 0.9 m1.2 m0.45 mnone
pool40.013 m at 2.6 m0.121.000.05 at 0.45 m0.5 mnonenone
river40.075 m at 5.6 m0.601.000.16 at 0.7 m0.8 m0.7 m1.3 m/s +x
swampnone—01.000.03 at 0.6 m0.6 m0.3 mnone
stylized40.55 m at 18 m1.000.850.18 at 1.2 m2.5 m2.2 mnone
baltic40.24 m at 14 m0.701.000.19 at 1.2 m2.5 m1.1 mnone
oil30.05 m at 6 m0.200.550.02 at 1.4 m0.9 mnonenone
mud20.09 m at 7.4 m0.250.300.11 at 0.8 m0.4 mnonenone
quicksand20.07 m at 6.6 m0.200.300.09 at 0.6 m0.3 mnonenone
silt40.07 m at 6.2 m0.451.000.13 at 0.75 m1.0 m0.5 mnone

None of that is authorable per material. The exceptions are waveScale, chopScale, flow, and the three the foam and the chop carry directly: foamWidthMeters, foamColor and chopWavelengthMeters. waveScale exists because a calmer version of the same body is a common ask and re-authoring a spectrum to get it is not; above 1 the summed steepness would pass the bound at which a Gerstner surface loops through itself, so the surface function clamps it — "waveScale": 3 is three times the amplitude with a rounder crest, not three times the sharpness. Zero is a real, authored value, not “unset falling back to the preset”: it is how a body says it is flat, and it also gates both foam terms off, since a surface with no crest has nothing to crest-foam and nothing to shoal against. chopScale is deliberately independent of waveScale — a still-looking pond keeps its shimmer at waveScale: 0 unless chopScale is zeroed too, and the two together are what make a body mirror-flat rather than merely calm.

The other three are the preset’s own numbers made sayable, because a body’s foam and its shimmer are the two things that read as place rather than as physics. foamWidthMeters is the horizontal reach already described above, so a canal wants a hairline where a beach wants a meter of wash; zero removes the shoreline band without touching the crest foam. foamColor is an sRGB reflectance, not an emission — the sun and the sky light it like any other surface, which is why a swamp’s foam is authored dirty rather than dimmed. chopWavelengthMeters sizes the longest of the four crossing slope layers, and the other three step down from it, so it is the one number that says whether a surface glitters finely or rolls; it must be positive, and a surface with no shimmer is chopScale: 0, never a zero wavelength.

Waves and foam can each be switched off wholesale at runtime — client.render.waterWaves, client.render.waterFoam, with client.render.waterWaveScale, client.render.waterFoamSoftness and client.render.waterFoamCoverage (the most of a texel foam may cover) for tuning, client.render.waterFoamPersistence and client.render.waterFoamResolution for the crest-foam history, client.render.waterMirrorBlur for the sky reflection’s footprint, and client.render.waterEdge (with waterMaxVertices, waterSurfaceAngle and waterRescaleRatio) for the subdivision that gives a surface enough vertices to carry a wave, applied live. They are client preferences, not material fields.

Every field in this block, the preset included, can also be retuned in a running map with material.set, which is how these numbers are meant to be found.

A current is not a wave. flow is a body’s water going somewhere:

millrace.dh-mat
{
"name": "Millrace",
"inherits": "core:materials/water-river.dh-mat",
"water": {
"flow": { "direction": [1, 0], "speed": 1.6 }
}
}
PropertyTypeRequiredDescription
direction[x, z]noThe current’s heading in the horizontal plane; normalized where it is read, so any length authors the same current
speedfloatnoHow fast the current carries the surface, in meters per second; must not be negative

The block is closed. A negative or non-finite speed is a build error — reverse a current by negating direction, not by making its speed negative — as is a direction that is not exactly two components, or one whose length is zero and so names no heading at all. Omit the block for still water, or author it EMPTY — "flow": {} — to still a parent or preset that flows; the two read the same except that the empty block also overrides what it inherits. Setting flow to still in the editor writes the empty block for exactly that reason. A flow whose direction names no heading is read as still and reported once on save.

Flow does two things. It advects the chop, the foam noise and the normal pattern, so the detail travels downstream; and it Doppler-shifts every Gerstner lane by k · dot(direction, flow), so the waves are carried along without their amplitudes or wavelengths changing. A river is a river because of this, not because its phase speed was multiplied: the old 1.45× dispersion hack is gone, and river now carries a 1.3 m/s current in +x as its preset default, which a material may still or redirect without touching the spectrum.

A Unity game has no water pass of DigitalHeaven’s, so the Unity runtime draws a water material on the host game’s own water when the mod supplies one, and otherwise on the pipeline’s Lit, transparent and tinted by the body’s medium. Both read the same numbers off the block; see Unity → Water.

PlatformColor and clarityMurkFoamWaves and chopFlowOptics and thin film
Engine✅✅✅✅✅✅
Unity, with a host water template✅✅🟡 colors only❌ the template’s own normals stay, its waves are flattened❌❌
Unity, without one🟡 one tint at a fixed depth✅❌❌❌❌

Every preset ships as a grid of three: water<Kind> is the base — dynamic but not flowing — water<Kind>Still inherits it with waveScale: 0, chopScale: 0 (mirror-flat), and water<Kind>Flowing inherits it with a flow in +x. river’s preset carries its own 1.3 m/s current, so its base authors an explicit empty "flow": {} to still it — an empty flow block wins wholesale over an inherited one and resolves to no current, which is how a variant stills a flowing parent. Eleven kinds by three variants is thirty-three materials, one naming rule, no exceptions. Each base states its preset and nothing else — a water block on a core base carrying a second field would be an authored override wearing a preset’s name.

KindBaseStillFlowing (speed)
lakewater-lakewater-lake-stillwater-lake-flowing (0.15 m/s)
oceanwater-oceanwater-ocean-stillwater-ocean-flowing (0.4 m/s)
poolwater-poolwater-pool-stillwater-pool-flowing (0.05 m/s)
riverwater-riverwater-river-stillwater-river-flowing (1.3 m/s)
swampwater-swampwater-swamp-stillwater-swamp-flowing (0.05 m/s)
stylizedwater-stylizedwater-stylized-stillwater-stylized-flowing (0.5 m/s)
balticwater-balticwater-baltic-stillwater-baltic-flowing (0.2 m/s)
oilwater-oilwater-oil-stillwater-oil-flowing (0.03 m/s)
mudwater-mudwater-mud-stillwater-mud-flowing (0.04 m/s)
quicksandwater-quicksandwater-quicksand-stillwater-quicksand-flowing (0.02 m/s)
siltwater-siltwater-silt-stillwater-silt-flowing (0.15 m/s)

Reflection is the sky in the mirror direction only — the analytic sky, or an image sky sampled at the mip its screen-space footprint asks for — and never a screen-space or probe reflection. Crest foam persists: each water body keeps a plan-view foam history, accumulated once a frame and faded so a texel is down to a faint residual after the preset’s foamPersistenceSeconds, so a wake stays where the wave left it. The shoreline band is measured against the depth behind the surface and so is evaluated fresh every frame. The waterline, the underwater state and caustics are all deferred; the engine’s rendering notes say where the pass sits and what it costs. A water block on a spawned dh.object is ignored: unlike the detail and decal tables, which a streamed object appends to, the water table is sealed when the map loads and only map-mesh and brush materials are routed through it.

A glass block makes the material a pane: one thin sheet, not the boundary of a solid. It draws in the transparent pass after the opaque block resolves, alongside water and off the same shared copy of the scene, and it is the presence of the block — not a shader name — that routes a material there.

{
"name": "Bottle Green Pane",
"shader": "lit",
"glass": {
"ior": 1.52,
"thickness": 0.02,
"tintColor": [0.72, 0.87, 0.78],
"roughness": 0.0,
"waviness": 0.3
}
}
FieldTypeRequiredDescription
iorfloatnoIndex of refraction over air, 1.0 – 3.0 (default: 1.52, soda-lime window glass). Sets both the Fresnel curve and how far the pane offsets what is behind it
thicknessfloatnoSheet thickness in meters, 0.0001 – 0.5 (default: 0.005)
tintColorcolornoThe color transmitted through thickness meters of the sheet, head-on (default: white)
roughnessfloatnoTransmission roughness, 0.0 – 1.0 (default: 0) — how etched the sheet is. Any value above zero draws the pane with the frosted pipeline
wavinessfloatnoRoller wave, in degrees of peak normal tilt, 0.0 – 5.0 (default: 0.3)
thinFilmThicknessfloatnoThin film on the pane, in nanometers, 0 – 25000 (default: 450)
thinFilmIorfloatnoIndex of the film over air, greater than 1 and at most 3 (default: 1.3330)
thinFilmWeightfloatnoHow much of the pane’s reflectance the film accounts for, 0.0 – 1.0; 0 is no film at all (default: 0)
thinFilmVariationfloatnoHow far the film’s thickness swings across the pane, in nanometers, 0 – 5000; 0 is one flat tint per angle (default: 0)
thinFilmSheenfloatnoA multiplier over the film’s chroma alone, 0.0 – 4.0; 1 is the physical interference term (default: 1)

The block folds child-wins as a whole block, like detail and matcap — a variant that authors a glass at all restates every field it wants.

This is the part that is not a matter of taste. Light refracts twice: into the sheet at the front face and back out at the rear one. Where the two faces are parallel the exit ray is parallel to the entry ray, and all that survives is a lateral shift of thickness * (tan θᵢ - tan θᵣ) — on a 5 mm pane, a fraction of a millimeter. Refracting once, at the front face only, is what makes a window bend like a solid block of glass, and it is wrong by roughly the ratio of the pane’s thickness to the distance behind it.

waviness is why a window distorts at all. A pane rolled dead flat shifts every ray by the same vector, which is a screen-space shift of zero — an authored 0 really does show no distortion. What a real window has is the roller wave a float line leaves: a long, shallow ripple that makes the two faces very slightly non-parallel, so the sheet is also a weak wedge and deviates as well as offsets. The default of three tenths of a degree is the real figure, and it reads the way the real thing does: barely visible head-on, plain at a grazing angle.

tintColor is Beer-Lambert over the path actually walked inside the sheet, so a glancing view darkens on its own — the reason a pane’s edge is green and its face is not. The reflection is Schlick from the ior (about 4% head-on, 9% at sixty degrees, over a third at eighty) against the reflection probe covering the pane, or the sky where no probe reaches.

What is behind a pane comes from a copy of the scene taken before any transparent surface draws, so a second pane behind this one shows through the first unshifted, and so does water. Unity, Godot and Source all stop in the same place, for the same reason: a grab pass has to be taken somewhere, and taking it after the transparents would mean ordering every transparent surface against every other one.

roughness softens the reflection, by picking a blurrier probe mip, and it also frosts what is seen through the sheet. The radius of that blur is roughness * gap * spread, in world meters, where the gap is the distance from the pane to whatever stands behind it — so a hand pressed against the glass stays sharp while the room behind it dissolves, which is what an etched pane actually does and what no fixed-radius blur can imitate. The world radius is carried to the screen through the perspective divide and capped in pixels, and the gather is a fixed-tap spiral turned per pixel, not a one-tap sample leaning on temporal reprojection — there are no motion vectors for refracted geometry to reproject along.

A pane with roughness above zero is drawn by a second pipeline built from the same shader body, so clear glass never carries the gather’s cost. Three client preferences tune it: client.render.glassFrostSpread (blur meters per meter of gap, per unit roughness), client.render.glassFrostTaps (taps per pane, up to 32) and client.render.glassFrostRadius (the widest the kernel may reach on screen, in pixels).

The five thinFilm* fields are the same film a water body wears, over the pane’s index instead of the liquid’s — the shared include described under Thin film computes both, and thinFilmWeight 0 is a pane with no film, taking the path glass took before films existed. What differs on glass is where the depth comes from: a coating was laid down once and stays where it was laid, so the field is read off the pane’s own tangent plane and carries no time at all, while water marbles its film along the current.

The index is the whole of whether a coated pane reads. A soap film’s 1.3330 sits between air and a pane’s 1.52, which is very nearly index-matched — correct, and a rainbow too faint to photograph. A dichroic pane is coated in a metal oxide for that reason, and an authored thinFilmIor up around 2.3 is what makes the bands carry.

The level editor’s Inspector shows a material as well as a node: click the round thumbnail on a Renderer material row and the window describes the dh.material itself, one card per group of this schema — Surface, Textures, UV, Water, Parallax, Stochastic. Every knob it offers goes out as a material.set line the server decides and replicates, so a session’s retuning is what everybody watching the map is looking at. That is every field in this schema, texture channels and the shading model included — binding a channel or changing the pass costs the slot a rebuild, which the client does off the main thread while the map keeps drawing.

The Water card reads in parts. It carries a mark beside its name, and inside it the preset sits on top — it seeds most of what follows — with the rest folded into seven collapsible sub-sections, each wearing a mark of its own: clarity (deep color, depth), waves (height), ripples (strength, size), foam (shore width, color), current (current), optics (index, dispersion, caustics) and physical (density, thickness). The words on screen are screen-only: the fields keep the names this schema gives them, so nothing an author wrote changes. Every row carries a one-sentence tooltip on hover saying what the eye sees when the number moves, and that sentence is where a field’s unit lives — the row itself shows a bare number.

Save writes that retuning back into a source. A material that already lives in the map’s own pallet is edited in place, as bytes — only the fields the session changed are touched, and the file’s comments, its ordering and its unauthored defaults come back out unchanged. A material the map does not own is never modified: the map’s pallet gains a child of it at materials/<baseName>.dh-mat, holding an inherits pointing at the original and only the overridden fields, and every slot wearing the old barcode is re-pointed at the new one. That is the same inheritance every hand-authored variant uses — a saved edit produces exactly the file you would have written yourself.

The write is a source edit, so the running world (which is showing a compiled pallet) matches only until it is reloaded. Compile the pallet to make it stick.

A dh.map material slot references a dh.material by barcode — a path read from the map’s own folder (materials/name.dh-mat from a map at the pallet root, or /materials/name.dh-mat from anywhere). The Engine map renderer honors all six texture channels (baseColor, normal, metallicRoughness, occlusion, emissive, height), the metallic / roughness / occlusionStrength / heightScale / heightReference / normalScale scalars, the parallax block, tint (linearized and multiplied into the albedo), shader ("lit" vs "unlit"), uvScale, emissiveColor and emissiveIntensity:

  • shader: "unlit" outputs tint×base with no lighting at all — e.g. a flat sky slot tinted a solid sRGB color, with no baseColor texture. Because the tint is linearized then re-encoded on present, the slot displays as its authored hex.
  • shader: "lit" (the default) runs the metallic-roughness BRDF below and adds linearize(emissiveColor) × emissiveIntensity × emissive — the emission mask gating where the glow lands.

Every channel a material does not bind falls back to a neutral 1×1 default — white for baseColor / metallicRoughness / occlusion / emissive, a flat (0.5, 0.5, 1.0) normal — so a material that declares only a base color shades exactly as it did before PBR landed.

Lit surfaces use a metallic-roughness BRDF: GGX/Trowbridge-Reitz normal distribution, Smith height-correlated visibility, Schlick Fresnel, with a Lambert diffuse lobe scaled by (1 − F) and (1 − metallic). Normal mapping is tangent-space; tangents come from the mesh’s glTF TANGENT attribute when present and are generated deterministically from UV deltas when not. This tangent basis is fixed per vertex and never re-derived per channel: a normal channel or detail block that samples a non-default uvSet is still perturbed against the same basis every other channel uses, so a seam normal on UV3 tilts shading exactly as a UV0 one would.

The diffuse term is wrapped Source-style (half-Lambert) rather than clamped at the terminator:

wrapped = (N·L × 0.5 + 0.5)²
diffuseNdL = mix(max(N·L, 0), wrapped, halfLambert)

world.lighting.halfLambert (default 1) blends between the two — 0 is a hard physical terminator, 1 is the full wrap. The same wrap shapes the hemisphere ambient’s up-facing ratio, so the two terminators agree. This is deliberately not energy-conserving; it is why a surface’s shadowed side keeps a soft fill instead of going black. The specular lobe is never wrapped — it keeps the hard max(dot, 0), because a highlight smeared onto a back face is simply wrong.

Ambient is a hemisphere gradient (a sky color overhead fading to a ground color underfoot), driven by the replicated world.ambient.* settings. A bound occlusion map darkens ambient only; direct light is untouched, since a baked AO map knows nothing about the lights in the scene.

Direct light is the sum of the world’s sun (always present, a dedicated pair of frame-uniform lanes) and every analytic light the frame selected — up to 64, ranked against the camera and uploaded to a set-0 light uniform block. Point and spot lights attenuate by inverse-square, windowed so the falloff reaches exactly zero at the light’s authored range; a spot additionally multiplies a squared cone ramp between its inner and outer angles. See dh.map → Lights for authoring.

The alphaMode property controls transparency rendering. Follows the glTF specification.

ModeDescription
"opaque"Fully opaque, alpha is ignored (default)
"mask"Binary transparency: pixels are either fully visible or fully invisible based on alphaCutoff
"blend"Smooth alpha blending with the background

Cutout (foliage, hair cards):

{
"alphaMode": "mask",
"alphaCutoff": 0.5
}

In the Engine a cutout occludes ambient occlusion by its texture: the occlusion prepass cuts it at the same alphaCutoff against the baseColor texture’s alpha on UV0. The sun’s shadow cascades cut it the same way, so a leaf, a grille or a fence casts its own shape rather than its whole quad. A transparent material casts no shadow at all: the cascades hold one depth per texel and have nowhere to keep a tint.

Transparent (glass, water):

{
"alphaMode": "blend",
"tint": [1.0, 1.0, 1.0, 0.5]
}

Unity and the Engine classify a material the same way, from one shared implementation (MaterialAlphaModes.Classify), so a material cannot look transparent in one and solid in the other.

  1. alphaMode is omitted — the tint alpha decides: below 1.0 the material blends, otherwise it is opaque. (A lens authored as [1, 1, 1, 0.01] with no mode is transparent.)

  2. alphaMode is present — it wins outright, whatever the tint says. Matching is case-insensitive, and Unity’s own Standard-shader spellings are accepted alongside the glTF ones:

    WrittenClassified as
    "mask", "cutout"mask
    "blend", "fade", "transparent"blend
    "opaque", anything unrecognizedopaque

A texture never changes the mode. A baseColor with an alpha channel on a material that states no alphaMode is opaque (unless the tint says otherwise, rule 1), because alpha is also a mask for other things: an envmap mask, a self-illumination mask, a phong mask. The compiler writes nothing into the material for you: alphaMode is the only source of blending. An importer states the mode explicitly, so a material it writes never depends on this fallback.

Unity’s "transparent" is premultiplied there and straight source-alpha in the Engine; nothing authored so far distinguishes them.

PassBlended material
Main scene passDrawn, after every opaque draw and after the sky, sorted back to front by view depth, with depth test on and depth write off. Lit exactly like an opaque draw.
Sun shadow cascadesSkipped — matching Unity, where a transparent material is off the shadow-caster path by default.
Screen-space occlusion prepassSkipped — depth-only, and a surface with no depth writes must not occlude. It also receives no screen-space occlusion: every tap it could take belongs to whatever stands behind it.
Selection-outline prepassSkipped, for the same reason.
WaterIts own later pass, untouched: water draws over transparents.
Motion blurReprojects depth only; there is no per-object velocity pass to skip.
Reflection probes, GI bakeCompile-time toolchain work, not runtime renderer passes.

Sorting is per drawable, on dot(worldCenter - cameraPosition, cameraForward). Sub-meshes of one mesh share that mesh’s bounds and therefore tie; among ties the smaller depthBias draws first, so a decal stacked over another in one mesh lands on top, and the rest keep submission order so the picture does not flicker between frames. There is no per-triangle sorting, so a blended surface that folds over itself can still self-order wrongly.

Setting a live material.set tint alpha below 1.0 on a material that authors no alphaMode turns blending on, exactly as authoring that alpha would have — and raising it back turns it off.

The cutout path is unchanged: a texel whose base-color alpha falls below alphaCutoff is discarded, it stays an opaque draw, and the shadow and occlusion passes cut it at the same alphaCutoff, so it casts the shadow of what it shows rather than of its whole card.

blendMode says how a blended surface combines with what is already drawn behind it. It is read only when the material blends, so alphaMode (or the tint, rule 1) still decides whether a surface blends and blendMode only decides how. On an opaque or masked material it changes nothing, and the compiler warns.

ModeThe frame becomesFor
"alpha" (default)src × a + dst × (1 − a)Glass, hair cards, anything see-through
"additive"dst + src × aGlows, sparks, light cards. The shaded color is added, lit or unlit as shader says
"multiply"dst × mix(1, src, a)Grime, tire tracks, tint masks. White leaves the frame as it was
"mod2x"dst × mix(1, 2 × src, a)Source’s modulate-times-two decals. Mid-gray leaves the frame as it was, darker texels darken it and lighter ones brighten it

src is the base color the material samples (tint times texture, after its detail and decals) and a is its coverage, the same tint-alpha-times-texture-alpha an "alpha" blend reads. So a clear texel leaves the frame untouched in every mode, and a texture’s alpha shapes a decal without a mask.

"multiply" and "mod2x" are not lit: they scale the light already in the frame, which is what a decal on a lit wall means. "mod2x" doubles in the gamma the color was authored in, the same factor as the detail layer’s mulx2 (2^2.2 in linear light), so #808080 multiplies by about 0.98 and lands within one 8-bit level of the surface under it.

tire-tracks.dh-mat
{
"shader": "unlit",
"textures": { "baseColor": "textures/tire-tracks.png" },
"alphaMode": "blend",
"blendMode": "mod2x",
"depthBias": 1
}

Every mode draws in the blended pass described above, in the same back-to-front order; a draw binds the mode’s pipeline when it differs from the one before. The three non-alpha modes leave the target’s alpha alone, so in a coverage capture (an avatar rendered for another engine to composite) a glow or a multiply adds no coverage.

Platformalphaadditivemultiplymod2xNotes
Engine, Windows desktop✅✅✅✅
Engine, Linux and macOS desktop✅❔❔❔The pipelines are core Vulkan; not yet run there
Engine, iOS✅❔❔❔The shader is shared; not yet run on a device
Unity runtime and editor import✅❌❌❌Drawn as "alpha"
Garry’s Mod and the other game platforms✅❌❌❌Drawn as "alpha"
Studio❔❌❌❌blendMode is not read

depthBias pulls a surface toward the camera in depth by a number of steps, so an overlay or decal lying exactly on another surface wins against it at every angle instead of flickering where their depths tie. It applies to opaque, masked and blended materials alike. 0 (the default) is no bias; a decal on a wall takes 1, and a decal stacked over that one takes 2.

A step is this client’s to size, because the right amount depends on the depth buffer and not on the material: each step is client.render.depthBiasConstant depth units of last place (default 4) plus client.render.depthBiasSlope times the surface’s steepest depth gradient per pixel (default 1). The constant half holds a surface seen head-on; the slope half holds it at a grazing angle, where the depth changes fastest across a pixel. Both apply on the next frame from a console line.

The Engine sets the bias as dynamic rasterizer state per draw, so every material shares one pipeline and a material with no bias draws exactly as it did. Among blended draws at the same sort depth the smaller bias draws first (see Which passes a blended material takes).

A bias only moves depth: the surface still has to lie on, or just in front of, what it covers. Sixteen steps is far short of where a pulled surface would show through what really stands in front of it.

PlatformdepthBiasNotes
Engine, Windows desktop✅Every scene pass; the occlusion, shadow and outline prepasses draw the surface unbiased
Engine, Linux and macOS desktop❔Core Vulkan; not yet run there
Engine, iOS❔Not yet run on a device
Unity runtime and editor import❌Ignored
Garry’s Mod and the other game platforms❌Ignored

The shader property selects which shader family to use. DigitalHeaven uses semantic shader names that map to the correct platform-specific shader at runtime.

ValueDescription
"lit"Standard PBR shader with lighting (default)
"unlit"Unlit shader, no lighting calculations
"water"The water pass, whose numbers come from the water block or its preset defaults

When omitted, defaults to "lit" — except on a material that authors a water block, which is water without saying so.

Shader ids are bare words, not namespaced: shader names a rendering model that every compile target maps to its own implementation (Unity’s Standard shader, a VRChat shader, the engine’s own pipeline), so it is semantic rather than a DigitalHeaven-owned asset and does not carry the dh. prefix reserved for things DH mints (patch ids, asset types). water is one of them: a material is water either by naming it or by authoring a water block, and the word wins when both are present — which is how a live retune moves a surface into the pass and back out again.

{
"shader": "unlit",
"textures": {
"baseColor": "textures/sign-diffuse.png"
}
}

The semantic shader is resolved before Unity platform overrides. A platform override’s shader field (which takes a raw Unity shader name) always takes priority.

The renderFace property controls which faces of the mesh are rendered (backface culling).

ValueDescription
"front"Render front faces only, cull back faces (default)
"back"Render back faces only, cull front faces
"both"Render both faces, no culling (double-sided)

When omitted, defaults to "front". The value inherits and overrides like any other field, and a live material.set retunes it on the next frame.

PlatformfrontbackbothNotes
Engine✅✅✅Every pass that draws the material’s geometry: opaque, alpha-masked, blended, the depth and AO prepasses, reflection-probe capture, the editor outline’s prepass and the material preview.
Unity (runtime)✅✅✅Built-in, URP and HDRP, through _Cull / _CullMode (HDRP also enables double-sided).
Unity editor import (VRChat)✅✅✅Same mapping as the runtime.
MegaBonk✅✅✅The Unity runtime’s mapping; a swap to the game’s MK Toon shader carries the cull across.
Source export (Garry’s Mod)✅❌✅"both" writes $nocull 1. Source materials have no front-face cull, so "back" draws the front.
Studio🟡🟡✅The preview draws every face whatever the value says.

In the Engine, a back face that a "both" or "back" material draws is lit as the side the camera sees: its normal and tangent frame are turned around before shading, so the inside of a petal is lit rather than black. A mirroring transform (a negative scale) keeps the authored front, so a flipped prop culls and lights the same faces as the original. The sun’s shadow map already renders both faces of every material, so a two-sided surface casts from either side with no change to its bias.

Double-sided material (foliage, cloth):

{
"textures": {
"baseColor": "textures/leaf-albedo.png"
},
"alphaMode": "mask",
"alphaCutoff": 0.5,
"renderFace": "both"
}

For Unity-specific shader settings, use platform overrides with raw shader property names. They live under platforms/unity/ (or the platform ID, platforms/com.unity.unity/):

  • Directorymaterials/
    • glass.dh-mat Universal material
  • Directoryplatforms/
    • Directoryunity/
      • Directorymaterials/
        • glass.dh-mat Unity-specific overrides

Unity override example:

platforms/unity/materials/glass.dh-mat
{
"shader": "Standard",
"properties": {
"_Metallic": 0.0,
"_Mode": "transparent"
},
"colors": {
"_Color": [1.0, 1.0, 1.0, 0.5]
}
}

The _Mode property accepts string values:

ValueDescription
"opaque"Standard opaque rendering
"cutout"Binary transparency using alpha cutoff
"fade"Alpha blending (fades out specular/reflections)
"transparent"Alpha blending (preserves specular/reflections)

Numeric values (0–3) also work.