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.
Properties
Section titled “Properties”| Property | Type | Required | Description |
|---|---|---|---|
$type | "dh.material" | no | Type identifier |
name | string | no | Display name |
description | string | no | Description |
inherits | string | no | Barcode of the material to inherit from |
textures | object | no | Texture channel assignments |
properties | object | no | Float material properties |
tint | color | no | Base color multiplier (RGBA), authored in sRGB (hex = face value) |
emissiveColor | color | no | Emissive color, authored in sRGB in [0, 1] (default: none/black) |
emissiveIntensity | float | no | Linear HDR multiplier for emissiveColor (default: 1) |
bakeEmission | bool | no | Whether 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" } | no | Scale applied to texture UVs before sampling (default: [1, 1]) |
uvScroll | [u, v] or { "u", "v" } | no | UV animation — how fast the textures slide, in UV units per second (default: still) |
flipbook | object | no | UV animation — the textures are an atlas of animation cells played off the world clock |
alphaMode | string | no | Alpha rendering mode |
alphaCutoff | float | no | Cutoff threshold for "mask" mode (default: 0.5) |
blendMode | string | no | How a blended surface combines with the frame: "alpha" (default), "additive", "multiply" or "mod2x"; read only while alphaMode blends |
depthBias | float | no | Depth bias — steps the surface is pulled toward the camera so it wins against the surface it lies on, 0 to 16 (default: 0) |
shader | string | no | Semantic shader ("lit", "unlit" or "water") |
renderFace | string | no | Face culling ("front", "back", or "both") |
surface | object | no | Physics/surface (surfaceprop) block — how the material behaves underfoot |
detail | object | no | Detail layer — a second channel set with its own uvScale |
parallax | object | no | Parallax march switches — shadow strength and depthWrite |
stochastic | bool | no | Stochastic sampling — hides tiling repetition, at ~3× the fetch cost (default: false) |
decals | array | no | Decals — up to four colors laid over the base albedo, each on its own UV set and shaped by a mask channel |
water | object | no | Water — 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
$typeis not needed in source files. The compiler adds it automatically during builds.
Full Example
Section titled “Full Example”{ "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.)
Texture Channels
Section titled “Texture Channels”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:
| Channel | Kind | Sampled | Description |
|---|---|---|---|
baseColor | color | RGB | Albedo / diffuse color, multiplied by tint |
normal | data | RGB | Tangent-space normal map, unpacked as t × 2 − 1 |
metallicRoughness | data | G, B | Combined metallic-roughness (glTF packing: roughness in G, metallic in B) |
occlusion | data | R | Ambient occlusion; darkens ambient only, never direct light |
emissive | color | RGB | Emission mask, multiplied into emissiveColor × emissiveIntensity |
height | data | R | Displacement 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 } } }| Property | Type | Required | Description |
|---|---|---|---|
texture | string | yes | The channel’s texture path, same rules as the bare-string form |
uvSet | int | no | Which 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.
@channel Extraction
Section titled “@channel Extraction”You can reference individual channels from packed textures using @channel syntax:
{ "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.
Float Properties
Section titled “Float Properties”| Property | Range | Default | Engine | Description |
|---|---|---|---|---|
metallic | 0.0 – 1.0 | 0 | ✅ | How metallic the surface is (0 = dielectric, 1 = conductor) |
roughness | 0.0 – 1.0 | 1 | ✅ | Surface roughness (1.0 = fully rough) |
occlusionStrength | 0.0 – 1.0 | 1 | ✅ | How strongly the occlusion map darkens ambient (0 ignores it) |
heightScale | 0.0 – 1.0 | 0 | ✅ | Depth of the height field, in UV units — see parallax occlusion |
heightReference | 0.0 – 1.0 | 0 | ✅ | Where the mesh surface sits in the height field — see the reference plane |
smoothness | 0.0 – 1.0 | — | — | Inverse of roughness (1.0 = mirror-smooth) |
normalScale | 0.0 – 8.0 | 1 | ✅ | Strength of the normal map — see normal strength |
emissiveIntensity | any | 1 | ✅ | 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
roughnessorsmoothness, not both. They’re inverses of each other. Converting a Unity material means writingroughness = 1 − smoothness— see Porting from Unity.
Color pipeline
Section titled “Color pipeline”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.
| Setting | Default | What it does |
|---|---|---|
world.render.exposure | 1 | Linear multiplier applied to scene radiance before the curve |
world.render.tonemap | reinhardWhite | Curve: 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, hableWhitePoint | 0.15, 0.50, 0.10, 0.20, 0.02, 0.30, 11.2 | The hable curve’s coefficients and white point; read only while the curve is hable. See the hable block |
world.render.bloom | true | Whether the world’s bright areas bloom |
world.render.bloomIntensity | 0.046 | Linear multiplier the resolved bloom pyramid is added back with. Clamped to 0–4 |
world.render.bloomThreshold | 1.721 | Linear radiance (max channel) above which a pixel blooms. Clamped to 0–64 |
world.render.bloomSoftKnee | 0.5 | Width of the knee below the threshold, as a fraction of it. Clamped to 0–1 |
world.render.bloomDiffusion | 6.2 | How far the bloom spreads, in pyramid levels. Clamped to 1–10 |
world.render.bloomAnamorphic | -0.24 | Aspect distortion of the glow. Clamped to -1–1 |
client.render.dither | true | Adds triangular-PDF interleaved gradient noise one 8-bit step wide, dissolving the banding smooth gradients otherwise show |
client.render.sharpen | false | RCAS sharpening of the tonemapped result. Client-only, and independent of Panini |
client.render.sharpenStrength | 0.5 | Strength of that sharpen over 0–1. 0 is an exact identity |
client.render.vignette | false | Vignette: darkens the frame corners on linear radiance, before the curve |
client.render.vignetteIntensity | 0.35 | How dark the corners go over 0–1. 0 is an exact identity |
client.render.vignetteSmoothness | 0.5 | Width of the vignette falloff as a fraction of the frame radius. Clamped to 0.05–1 |
client.render.chromaticAberration | false | Chromatic aberration: splits red and blue radially outward |
client.render.chromaticAberrationStrength | 0.35 | How far they separate at the corners, over 0–1. 0 is an exact identity |
client.render.parallaxFadeStart | 3 | Mip level the parallax march starts fading out at. Clamped to 0–16 |
client.render.parallaxFadeLength | 5 | Mip levels that fade spans. Clamped to 0–16; 0 is a hard cutoff |
client.render.parallaxShadowTaps | 16 | Taps the height-field self-shadow marches toward each light. Whole numbers, 0–32; 0 turns it off |
client.render.parallaxShadowLights | true | Whether that self-shadow also marches toward point and spot lights, not only the sun |
client.render.parallaxDepthWrite | true | Whether 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:
| Override | Default | What it does |
|---|---|---|
client.render.exposure | unset | While unset, follows world.render.exposure. Setting it pins your own value |
client.render.tonemap | unset | While unset, follows world.render.tonemap. Setting it pins your own curve |
client.render.bloom | unset | While unset, follows world.render.bloom. Setting it pins bloom on or off for you |
client.render.bloomIntensity | unset | While unset, follows world.render.bloomIntensity |
client.render.bloomThreshold | unset | While unset, follows world.render.bloomThreshold |
client.render.bloomSoftKnee | unset | While unset, follows world.render.bloomSoftKnee |
client.render.bloomDiffusion | unset | While unset, follows world.render.bloomDiffusion |
client.render.bloomAnamorphic | unset | While 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:
| Preference | Default | What it does |
|---|---|---|
client.render.bloomFast | false | Swaps 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 | -1 | Diagnostic: 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.exposureclient.render.exposure = 1.6 (from world; not overridden)] client.render.exposure 2.2] client.render.exposureclient.render.exposure = 2.2 (overriding world 1.6)] reset client.render.exposureBecause 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.
The pyramid
Section titled “The pyramid”- 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.
- A 13-tap box downsample halves the base repeatedly — five overlapping 2×2 boxes, center
weighted
0.5and corners0.125each. The overlap is what removes the pulsing a naive 2×2 halving shows when the camera moves a sub-texel amount. - 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.
Why the level count is derived, not fixed
Section titled “Why the level count is derived, not fixed”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.
The Karis average
Section titled “The Karis average”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 threshold is keyed on the max channel
Section titled “The threshold is keyed on the max channel”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.
The anamorphic ratio
Section titled “The anamorphic ratio”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.
Where the composite happens
Section titled “Where the composite happens”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.
Tuning bloom from the settings screen
Section titled “Tuning bloom from the settings screen”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.
Sun shadows
Section titled “Sun shadows”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:
| Preference | Default | What it does |
|---|---|---|
client.render.shadowResolution | 2048 | Side 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.shadowDistance | 120 | How 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.cascades | false | Tints 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.
When a material will not resolve
Section titled “When a material will not resolve”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.
Surface (physics)
Section titled “Surface (physics)”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.
{ "name": "Ice", "textures": { "baseColor": "textures/ice.png" }, "surface": { "frictionMultiplier": 0.15 }}Fields
Section titled “Fields”| Property | Type | Required | Description |
|---|---|---|---|
preset | string | no | One of the shipped surface presets below (default: stone) |
kind | string | no | What 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) |
frictionMultiplier | float | no | Grip a player standing on this surface experiences, 0.0 – 1.0 (default: the preset’s) |
density | float | no | Mass per cubic meter, in kg/m³; must be positive (default: the preset’s) |
hardness | float | no | How rigid the surface is, 0.0 – 1.0 (default: the preset’s) |
penetrationModifier | float | no | A multiplier over how easily a projectile penetrates this surface; must not be negative (default: the preset’s) |
damageModifier | float | no | A multiplier over damage dealt on this surface; must not be negative (default: the preset’s) |
footstepSet | string | no | Which footstep sound set plays walking on this surface (default: the preset’s) |
scrapeSet | string | no | Which scrape sound set plays sliding against this surface (default: the preset’s) |
impactSet | string | no | Which impact sound set plays a projectile hitting this surface (default: the preset’s) |
acousticMaterial | string | no | Which 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.
Surface presets
Section titled “Surface presets”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).
preset | kind | frictionMultiplier | density (kg/m³) | hardness |
|---|---|---|---|---|
stone (default) | stone | 1.0 | 2400 | 0.9 |
metal | metal | 0.9 | 7850 | 0.95 |
wood | wood | 0.9 | 700 | 0.5 |
dirt | dirt | 0.85 | 1500 | 0.2 |
grass | grass | 0.8 | 1200 | 0.15 |
gravel | gravel | 0.75 | 1800 | 0.35 |
sand | sand | 0.7 | 1600 | 0.1 |
glass | glass | 0.95 | 2500 | 0.85 |
flesh | flesh | 0.6 | 1050 | 0.1 |
plastic | plastic | 0.85 | 950 | 0.4 |
fabric | fabric | 0.7 | 300 | 0.05 |
ceramic | ceramic | 0.95 | 2200 | 0.8 |
ice | ice | 0.12 | 917 | 0.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.
Surface overrides (per-world)
Section titled “Surface overrides (per-world)”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).
Emissive color
Section titled “Emissive color”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 * emissiveMaskemissiveIntensity 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)}Emission mask
Section titled “Emission mask”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).rgbWithout 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.
{ "textures": { "baseColor": "/textures/lampTexture.png", "emissive": "/textures/lampTexture.png" // same texture as the mask }, "emissiveColor": [1.0, 0.48, 0.0], "emissiveIntensity": 2.0}UV scale
Section titled “UV scale”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 } }UV animation
Section titled “UV animation”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.
{ "textures": { "baseColor": "textures/conveyor.png" }, "uvScroll": [0.25, 0]}{ "textures": { "baseColor": "textures/caustics.png" }, "alphaMode": "blend", "flipbook": { "columns": 8, "rows": 8, "frames": 60, "fps": 30 }}| Field | Type | Required | Description |
|---|---|---|---|
uvScroll | [u, v] | no | UV units per second on each axis (default: [0, 0], still) |
flipbook.columns | int | no | Cells across the atlas, 1 – 64 (default: 1) |
flipbook.rows | int | no | Cells down the atlas, 1 – 64 (default: 1) |
flipbook.frames | int | no | How many cells play, counted from the first, 1 – columns × rows; the rest of a part-filled atlas is never shown (default: every cell) |
flipbook.fps | float | no | Frames per second, 0 – 240; 0 holds the first frame (default: 30) |
flipbook.loop | bool | no | Repeat 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 (
uvScaleabove 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 / fpsseconds a non-looping flipbook is a still of its last frame. Use it for captures and fixed states; leaveloopon for anything a player watches. - The clock wraps every
client.render.worldTimeWrapseconds (default 3600) to keep float precision. A scroll or a flipbook shows a one-frame jump at the wrap unlessrate × periodis a whole number of textures, andframesdividesfps × 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.
| Platform | UV 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 |
Normal strength
Section titled “Normal strength”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:
0is flat: the normal map contributes nothing.1(default) is the map as authored.- Above
1exaggerates it.1.25–1.5is 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.
Specular antialiasing
Section titled “Specular antialiasing”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.
| Preference | Default | What it does |
|---|---|---|
client.render.specAa | true | The filter itself |
client.render.specAaSigma | 0.25 | Screen-space filter width. Higher spreads more, and blurs more highlights that were not aliasing |
client.render.specAaKappa | 0.18 | Ceiling on how much roughness one pixel may gain, so a silhouette cannot go matte |
client.render.roughnessFloor | 0.045 | Lowest 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.
Parallax occlusion
Section titled “Parallax occlusion”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.
{ "textures": { "baseColor": "textures/brick-albedo.dh-tex", "normal": "textures/brick-normal.dh-tex", "height": "textures/brick-height.dh-tex" }, "properties": { "heightScale": 0.03 }}The reference plane
Section titled “The reference plane”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:
heightReference | Plane | Relief |
|---|---|---|
0 (default) | white | carves entirely inward |
0.5 | mid-gray | displaces both ways |
1 | black | pushes 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.
heightScale is in UV units, not meters
Section titled “heightScale is in UV units, not meters”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 / worldUnitsPerUvUnitA 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.
Cost, and where it stops
Section titled “Cost, and where it stops”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.
| Preference | Default | Description |
|---|---|---|
client.render.parallaxFadeStart | 3 | Mip level the fade begins at |
client.render.parallaxFadeLength | 5 | Mip 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.
Self-shadowing
Section titled “Self-shadowing”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.
{ "textures": { "height": "textures/brick-height.dh-tex" }, "properties": { "heightScale": 0.03 }, "parallax": { "shadow": 0.8 }}| Field | Default | Description |
|---|---|---|
parallax.shadow | 0 | How 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.
Writing the marched depth
Section titled “Writing the marched depth”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.
{ "textures": { "height": "textures/cobbles-height.dh-tex" }, "properties": { "heightScale": 0.15 }, "parallax": { "shadow": 0.6, "depthWrite": true }}| Field | Default | Description |
|---|---|---|
parallax.depthWrite | false | Write 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:
| Preference | Default | Description |
|---|---|---|
client.render.parallaxDepthWrite | true | Whether 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 sampling
Section titled “Stochastic sampling”"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.
{ "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.
Cost, and how it composes
Section titled “Cost, and how it composes”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
stochasticswitch, 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.
When not to use it
Section titled “When not to use it”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.
Detail layer
Section titled “Detail layer”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:
{ "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" } }}Fields
Section titled “Fields”| Property | Type | Required | Description |
|---|---|---|---|
textures | object | no | Detail channel assignments (see below) |
uvScale | [u, v] or { "u", "v" } | no | The detail layer’s own UV scale (default: [1, 1]) |
uvSet | int | no | Which of the mesh’s UV sets the whole detail block samples, 0 – 3 (default: 0) |
tint | color | no | sRGB tint multiplied into the sampled detail baseColor (default: white) |
strength | object | no | Per-channel strength in 0.0 – 1.0 |
blend | object | no | Per-channel blend mode |
stochastic | bool | no | Stochastic sampling for the detail channels, switched separately from the base layer (default: false) |
vertexWeight | "r" | "g" | "b" | "a" | no | Which channel of the mesh’s COLOR_1 paints the layer on per vertex (default: none, the layer applies everywhere) |
blendSoftness | 0.0 – 1.0 | no | How wide the edge is where the weight crosses the blendMask (default: 0.1) |
blendMaskUvScale | [u, v] or { "u", "v" } | no | The 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:
{ "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:
| Channel | Kind | Sampled | Blended into |
|---|---|---|---|
baseColor | color | RGB | Albedo, after tint and the base baseColor |
normal | data | RGB | Base tangent-space normal, whiteout-combined |
metallicRoughness | data | G, B | Roughness (G) and metallic (B) separately |
occlusion | data | R | The base occlusion sample, before occlusionStrength |
height | data | R | Nothing — it is marched, not blended |
blendMask | data | R | Nothing — 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:
| Key | Default strength | Default blend |
|---|---|---|
baseColor | 1 | mul |
normal | 1 | (strength only — normals always whiteout-combine) |
metallic | 0 | mul |
roughness | 1 | mul |
occlusion | 1 | mul |
height | 0 | (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.
Detail height
Section titled “Detail height”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.
{ "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.
Blend modes
Section titled “Blend modes”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:
| Name | Math |
|---|---|
add | s + d·t |
alpha | s − d·t |
mul | s × mix(1, d, t) — the default |
mulx2 | s × mix(1, d × 4.5948, t) (unity_ColorSpaceDouble taken into linear) |
overlay | mix(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 |
screen | s + d·t − s·(d·t) |
lerp | mix(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))).
Painting the layer on by vertex color
Section titled “Painting the layer on by vertex color”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.
Breaking up the edge with a blend mask
Section titled “Breaking up the edge with a blend mask”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; ablendMaskwith none is a compile error, since it would change nothing. - Sampling. The mask is read on the block’s own
uvSetatblendMaskUvScale, or at the block’suvScalewhen 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
blendMaskpays 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.
| Platform | Blend 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 |
Letting the heights decide the edge
Section titled “Letting the heights decide the edge”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 exactlyw 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.mis the mask’s R at the pixel, so the mask sets the edge and the heights bend it. Without one,mis0.5: the edge is halfway across the paint wherever the heights are equal. - Needs a
vertexWeightand a detailheight. Both missing are compile errors. A base with noheighttexture counts as0.5everywhere, 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 (
uvScalefor the base, the detail’suvScalefor the detail), before the parallax march and unmoved by UV animation. A height blend does not needheightScaleorstrength.height: the heights decide the edge even with the march off. - Cost. It shares the blend mask’s pipeline variant and its
blendSoftnesslane; a material with neither pays nothing, and its row in the layer table is byte for byte what it was.
| Platform | Height 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 |
Inheritance
Section titled “Inheritance”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.
What is not here yet
Section titled “What is not here yet”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.
Decals
Section titled “Decals”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.
{ "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.
Fields
Section titled “Fields”| Property | Type | Required | Description |
|---|---|---|---|
texture | string | no | The decal’s color texture, decoded on sample like the base albedo. Omit it for a flat tint shaped entirely by the mask |
uvSet | int | no | Which of the mesh’s UV sets places the decal, 0 – 3 (default: 0) |
tint | color | no | sRGB color multiplied into the sample (default: white). Its alpha shapes the decal’s own coverage |
blend | string | no | How the decal combines with what is under it (see below, default: normal) |
blendAlpha | float | no | The layer’s overall weight, 0.0 – 1.0 (default: 1) |
mask | string | no | A data texture whose maskChannel multiplies the decal’s alpha |
maskChannel | string | no | r, g, b or a (default: r) |
maskUvSet | int | no | Which UV set samples the mask, 0 – 3 (default: 0) |
position | [u, v] | no | Offset in UV units (default: the origin) |
scale | [u, v] | no | Size in UV units (default: [1, 1]) |
rotation | float | no | Degrees 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.
Decal blend modes
Section titled “Decal blend modes”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:
| Name | Math |
|---|---|
normal | d — the default |
darken | min(s, d) |
multiply | s × d |
colorBurn | 1 − min(1, (1 − s) / d) |
lighten | max(s, d) |
screen | s + d − s·d |
add | s + d |
overlay | s ≤ 0.5 ? 2·s·d : 1 − 2(1 − s)(1 − d) |
subtract | s − 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.
Platform support
Section titled “Platform support”| Platform | Decals |
|---|---|
| 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 |
Inheritance
Section titled “Inheritance”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.
What is not here yet
Section titled “What is not here yet”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.
Matcap
Section titled “Matcap”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.
{ "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.
Fields
Section titled “Fields”| Property | Type | Required | Description |
|---|---|---|---|
texture | string | yes | The matcap sphere, a color texture. Without it the block shades nothing and the compiler warns |
mask | string | no | A data texture whose maskChannel scales strength per texel (default: unmasked, which is full strength everywhere) |
maskChannel | string | no | r, g, b or a (default: r) |
maskUvSet | int | no | Which UV set samples the mask, 0 – 3 (default: 0) |
blend | string | no | How the matcap combines with the lit color (see below, default: add) |
strength | float | no | The matcap’s weight, 0.0 – 4.0 (default: 1) |
lighting | string | no | lit or unlit: whether the matcap goes dark with its surface (see below, default: lit) |
tint | color | no | sRGB 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.
Matcap blend modes
Section titled “Matcap blend modes”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:
| Name | Math |
|---|---|
add | lit + m·w — the rim and sheen look, and the default |
replace | mix(lit, m, clamp(w, 0, 1)) — the lighting-replacement look |
multiply | mix(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.
Matcap lighting
Section titled “Matcap lighting”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 channelso 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.
Porting from Poiyomi
Section titled “Porting from Poiyomi”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.
| Poiyomi | Here |
|---|---|
| Replace, Add or Multiply weight | blend with that weight as strength, lighting: "lit" (the default) |
| Unlit Add | blend: "add", lighting: "unlit" |
| Emission Strength | blend: "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 |
Readability, not cosmetics
Section titled “Readability, not cosmetics”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.
Inheritance
Section titled “Inheritance”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.
What is not here yet
Section titled “What is not here yet”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.
Envmap
Section titled “Envmap”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.
{ "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.
Fields
Section titled “Fields”| Property | Type | Required | Description |
|---|---|---|---|
tint | color | no | $envmaptint, sRGB, multiplied into the reflection (default: white) |
mask | object | no | Where the per-texel strength comes from (default: unmasked) |
mask.texture | string | for texture | $envmapmask: a data texture whose RGB scales the reflection per channel, at the material’s own UVs |
mask.from | string | no | texture, 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) |
fresnel | float | no | $fresnelreflection: the strength head-on, rising to full at grazing angles, 0 – 1 (default: 1, no falloff) |
contrast | float | no | $envmapcontrast, 0 – 1 (default: 0) |
saturation | float | no | $envmapsaturation, 0 – 1 (default: 1) |
lightScale | float | no | $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.
| Platform | Envmap |
|---|---|
| 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.
{ "name": "Harbor", "water": { "preset": "ocean", "transmittanceColor": [0.45, 0.78, 0.88], "atDistance": 4.0 }}Fields
Section titled “Fields”| Property | Type | Required | Description |
|---|---|---|---|
preset | string | no | One of ocean, lake, pool, river, swamp, stylized, baltic, oil, mud, quicksand, silt (default: lake) |
transmittanceColor | color | no | The color the water transmits after atDistance meters, authored in sRGB (default: the preset’s) |
atDistance | float | no | The distance in meters at which transmittanceColor is reached; must be positive (default: the preset’s) |
scatter | float | no | How 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) |
scatterColor | color | no | What the suspended matter itself is colored, in sRGB, before the sky lights it; ignored where scatter is zero (default: the preset’s) |
fogColor | color | no | The 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) |
waveScale | float | no | A 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) |
chopScale | float | no | A multiplier over the preset’s normal-only chop; zero removes it, negative means 1 (default: 1) |
normalStrength | float | no | How 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 |
normalMix | float | no | The 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 |
normalScale | float | no | How many meters one tile of the normal texture spans, at least 0.01 (default: 2) |
normalScale2 | float | no | Reads 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] | no | The second read’s drift in tiles per second; the first read drifts at the material’s own uvScroll |
refractionScale | float | no | A 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) |
reflectionScale | float | no | A 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) |
foamWidthMeters | float | no | How far the shoreline foam reaches up the bank, in horizontal meters; zero removes the band, negative is a build error (default: the preset’s) |
foamColor | color | no | The foam’s sRGB reflectance, lit by the sun and sky like any other surface (default: the preset’s) |
chopWavelengthMeters | float | no | The longest crossing chop layer’s size in meters; must be positive (default: the preset’s) |
flow | object | no | Flow — a current in the horizontal plane (default: the preset’s, which is none for everything but river) |
density | float | no | The 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) |
viscosity | float | no | How 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) |
ior | float | no | The liquid’s index of refraction over air; must be greater than 1 (default: derived from the preset’s density) |
dispersion | float | no | How far the liquid spreads a ray by wavelength, 0 to 1; 0 removes the fringing (default: derived from ior) |
causticStrength | float | no | A multiplier over the caustics the body casts; 0 removes them, negative is a build error (default: 1) |
totalInternalReflection | bool | no | Whether 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) |
thinFilmThickness | float | no | How deep the surface film is in nanometers, 0 to 25000 — see Thin film (default: 450) |
thinFilmIor | float | no | The film’s own index of refraction over air, greater than 1 and at most 3 (default: 1.3330) |
thinFilmWeight | float | no | How 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) |
thinFilmVariation | float | no | How 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) |
thinFilmSheen | float | no | A 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.
Presets are the interface
Section titled “Presets are the interface”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.
| Preset | Transmits (sRGB) | At | Haze | Haze color (sRGB) |
|---|---|---|---|---|
ocean | [0.45, 0.78, 0.88] | 4 m | 0 | — |
lake | [0.55, 0.62, 0.45] | 2 m | 0 | — |
pool | [0.80, 0.92, 0.96] | 6 m | 0 | — |
river | [0.50, 0.50, 0.35] | 1 m | 0 | — |
swamp | [0.35, 0.33, 0.20] | 0.5 m | 0 | — |
stylized | [0.30, 0.75, 0.85] | 3 m | 0 | — |
baltic | [0.55, 0.58, 0.24] | 3 m | 0 | — |
oil | [0.05, 0.04, 0.03] | 0.25 m | 0 | — |
mud | [0.45, 0.18, 0.02] | 0.05 m | 0.95 | [0.40, 0.26, 0.13] |
quicksand | [0.62, 0.42, 0.10] | 0.04 m | 0.95 | [0.68, 0.56, 0.36] |
silt | [0.62, 0.52, 0.32] | 2.5 m | 0.6 | [0.72, 0.58, 0.36] |
Murk is two things
Section titled “Murk is two things”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.
| Preset | Density | A submerged swimmer |
|---|---|---|
ocean | 1025 | lifts — salt water carries you up; a diver swims down |
lake | 1000 | hangs — the reference fresh water, zero gravity |
pool | 1000 | hangs |
river | 1000 | hangs — a current carries you along, not up |
swamp | 960 | sinks — thin, dead water that will not hold you |
stylized | 1060 | lifts — buoyant by art direction, like the color |
baltic | 1005 | lifts a little — seven parts per thousand of salt, a fifth of the ocean’s |
oil | 900 | sinks, hard — a body put into it goes straight to the bottom |
mud | 1700 | lifts — nothing sinks in it; a crate rides on top, and at viscosity 4 every stroke is a quarter as fast |
quicksand | 2000 | lifts 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 |
silt | 1010 | lifts 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.
Optics
Section titled “Optics”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.
| Preset | Density | Index | Dispersion | Head-on reflectance |
|---|---|---|---|---|
ocean | 1025 | 1.3395 | 0.103 | 0.0207 |
lake | 1000 | 1.3330 | 0.100 | 0.0200 |
pool | 1000 | 1.3330 | 0.100 | 0.0200 |
river | 1000 | 1.3330 | 0.100 | 0.0200 |
swamp | 960 | 1.3226 | 0.096 | 0.0189 |
stylized | 1060 | 1.3486 | 0.108 | 0.0216 |
baltic | 1005 | 1.3343 | 0.101 | 0.0201 |
oil | 900 | 1.4700 (stated) | 0.183 | 0.0355 |
mud | 1700 | 1.5150 | 0.222 | 0.0412 |
quicksand | 2000 | 1.5930 | 0.314 | 0.0513 |
silt | 1010 | 1.3356 | 0.101 | 0.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.
Thin film
Section titled “Thin film”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:
thinFilmThicknessis the depth in nanometers. Near-grazing the hue walks a full turn about every 250 nm, which is what sets the swing below.thinFilmIoris the film’s own index. It is read against the body’sioron 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.thinFilmVariationis 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.thinFilmSheenmultiplies 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;oilships 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.
Waves and foam
Section titled “Waves and foam”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.
| Preset | Lane lines (degrees, longest wave first) |
|---|---|
ocean | 15 / 55 / 120 / 160 |
lake | 15 / 40 / 75 / 145 |
pool | 25 / 60 / 130 / 165 |
river | 15 / 50 / 120 / 155 |
stylized | 20 / 60 / 125 / 165 |
baltic | 30 / 67 / 105 / 132 |
oil | 15 / 55 / 130 |
mud | 20 / 60 |
quicksand | 30 / 105 |
silt | 37 / 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.
| Preset | Waves | Longest | Steepness | Phase | Chop | Shore fade | Foam width | Flow |
|---|---|---|---|---|---|---|---|---|
ocean | 4 | 0.42 m at 26 m | 0.85 | 1.00 | 0.22 at 1.6 m | 3.5 m | 1.6 m | none |
lake | 4 | 0.085 m at 7.4 m | 0.50 | 1.00 | 0.14 at 0.9 m | 1.2 m | 0.45 m | none |
pool | 4 | 0.013 m at 2.6 m | 0.12 | 1.00 | 0.05 at 0.45 m | 0.5 m | none | none |
river | 4 | 0.075 m at 5.6 m | 0.60 | 1.00 | 0.16 at 0.7 m | 0.8 m | 0.7 m | 1.3 m/s +x |
swamp | none | — | 0 | 1.00 | 0.03 at 0.6 m | 0.6 m | 0.3 m | none |
stylized | 4 | 0.55 m at 18 m | 1.00 | 0.85 | 0.18 at 1.2 m | 2.5 m | 2.2 m | none |
baltic | 4 | 0.24 m at 14 m | 0.70 | 1.00 | 0.19 at 1.2 m | 2.5 m | 1.1 m | none |
oil | 3 | 0.05 m at 6 m | 0.20 | 0.55 | 0.02 at 1.4 m | 0.9 m | none | none |
mud | 2 | 0.09 m at 7.4 m | 0.25 | 0.30 | 0.11 at 0.8 m | 0.4 m | none | none |
quicksand | 2 | 0.07 m at 6.6 m | 0.20 | 0.30 | 0.09 at 0.6 m | 0.3 m | none | none |
silt | 4 | 0.07 m at 6.2 m | 0.45 | 1.00 | 0.13 at 0.75 m | 1.0 m | 0.5 m | none |
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:
{ "name": "Millrace", "inherits": "core:materials/water-river.dh-mat", "water": { "flow": { "direction": [1, 0], "speed": 1.6 } }}| Property | Type | Required | Description |
|---|---|---|---|
direction | [x, z] | no | The current’s heading in the horizontal plane; normalized where it is read, so any length authors the same current |
speed | float | no | How 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.
Outside the engine
Section titled “Outside the engine”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.
| Platform | Color and clarity | Murk | Foam | Waves and chop | Flow | Optics 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 | ✅ | ❌ | ❌ | ❌ | ❌ |
The core water set
Section titled “The core water set”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.
| Kind | Base | Still | Flowing (speed) |
|---|---|---|---|
lake | water-lake | water-lake-still | water-lake-flowing (0.15 m/s) |
ocean | water-ocean | water-ocean-still | water-ocean-flowing (0.4 m/s) |
pool | water-pool | water-pool-still | water-pool-flowing (0.05 m/s) |
river | water-river | water-river-still | water-river-flowing (1.3 m/s) |
swamp | water-swamp | water-swamp-still | water-swamp-flowing (0.05 m/s) |
stylized | water-stylized | water-stylized-still | water-stylized-flowing (0.5 m/s) |
baltic | water-baltic | water-baltic-still | water-baltic-flowing (0.2 m/s) |
oil | water-oil | water-oil-still | water-oil-flowing (0.03 m/s) |
mud | water-mud | water-mud-still | water-mud-flowing (0.04 m/s) |
quicksand | water-quicksand | water-quicksand-still | water-quicksand-flowing (0.02 m/s) |
silt | water-silt | water-silt-still | water-silt-flowing (0.15 m/s) |
What is not here yet
Section titled “What is not here yet”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 }}Fields
Section titled “Fields”| Field | Type | Required | Description |
|---|---|---|---|
ior | float | no | Index 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 |
thickness | float | no | Sheet thickness in meters, 0.0001 – 0.5 (default: 0.005) |
tintColor | color | no | The color transmitted through thickness meters of the sheet, head-on (default: white) |
roughness | float | no | Transmission roughness, 0.0 – 1.0 (default: 0) — how etched the sheet is. Any value above zero draws the pane with the frosted pipeline |
waviness | float | no | Roller wave, in degrees of peak normal tilt, 0.0 – 5.0 (default: 0.3) |
thinFilmThickness | float | no | Thin film on the pane, in nanometers, 0 – 25000 (default: 450) |
thinFilmIor | float | no | Index of the film over air, greater than 1 and at most 3 (default: 1.3330) |
thinFilmWeight | float | no | How much of the pane’s reflectance the film accounts for, 0.0 – 1.0; 0 is no film at all (default: 0) |
thinFilmVariation | float | no | How far the film’s thickness swings across the pane, in nanometers, 0 – 5000; 0 is one flat tint per angle (default: 0) |
thinFilmSheen | float | no | A 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.
A pane offsets, it does not bend
Section titled “A pane offsets, it does not bend”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.
Glass cannot refract glass
Section titled “Glass cannot refract glass”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.
Frost blurs by the gap, not by a radius
Section titled “Frost blurs by the gap, not by a radius”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).
A coated pane
Section titled “A coated pane”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.
Editing one in the level editor
Section titled “Editing one in the level editor”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.
Use in maps
Section titled “Use in maps”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"outputstint×base with no lighting at all — e.g. a flat sky slot tinted a solid sRGB color, with nobaseColortexture. 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 addslinearize(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.
Shading model
Section titled “Shading model”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.
Alpha Modes
Section titled “Alpha Modes”The alphaMode property controls transparency rendering. Follows the glTF specification.
| Mode | Description |
|---|---|
"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]}The classification rule
Section titled “The classification rule”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.
-
alphaModeis omitted — thetintalpha decides: below1.0the material blends, otherwise it is opaque. (A lens authored as[1, 1, 1, 0.01]with no mode is transparent.) -
alphaModeis 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:Written Classified 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.
Which passes a blended material takes
Section titled “Which passes a blended material takes”| Pass | Blended material |
|---|---|
| Main scene pass | Drawn, 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 cascades | Skipped — matching Unity, where a transparent material is off the shadow-caster path by default. |
| Screen-space occlusion prepass | Skipped — 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 prepass | Skipped, for the same reason. |
| Water | Its own later pass, untouched: water draws over transparents. |
| Motion blur | Reprojects depth only; there is no per-object velocity pass to skip. |
| Reflection probes, GI bake | Compile-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.
Blending with the frame
Section titled “Blending with the frame”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.
| Mode | The frame becomes | For |
|---|---|---|
"alpha" (default) | src × a + dst × (1 − a) | Glass, hair cards, anything see-through |
"additive" | dst + src × a | Glows, 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.
{ "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.
| Platform | alpha | additive | multiply | mod2x | Notes |
|---|---|---|---|---|---|
| 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 |
Depth bias
Section titled “Depth bias”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.
| Platform | depthBias | Notes |
|---|---|---|
| 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 |
Shader
Section titled “Shader”The shader property selects which shader family to use. DigitalHeaven uses semantic shader names that map to the correct platform-specific shader at runtime.
| Value | Description |
|---|---|
"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.
Render Face
Section titled “Render Face”The renderFace property controls which faces of the mesh are rendered (backface culling).
| Value | Description |
|---|---|
"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.
| Platform | front | back | both | Notes |
|---|---|---|---|---|
| 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"}Unity Platform Overrides
Section titled “Unity Platform Overrides”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:
{ "shader": "Standard", "properties": { "_Metallic": 0.0, "_Mode": "transparent" }, "colors": { "_Color": [1.0, 1.0, 1.0, 0.5] }}The _Mode property accepts string values:
| Value | Description |
|---|---|
"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.