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) |
uvScale | [u, v] or { "u", "v" } | no | Scale applied to texture UVs before sampling (default: [1, 1]) |
alphaMode | string | no | Alpha rendering mode |
alphaCutoff | float | no | Cutoff threshold for "mask" mode (default: 0.5) |
shader | string | no | Semantic shader ("lit" or "unlit") |
renderFace | string | no | Face culling ("front", "back", or "both") |
surface | object | no | Physics/surface (surfaceprop) block — how the material behaves underfoot |
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"}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 |
Paths are relative to the pallet root, or barcodes for cross-pallet references.
The channel set is closed in both directions: there is no height/parallax, no detail map and no
second UV set. 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) |
smoothness | 0.0 – 1.0 | — | — | Inverse of roughness (1.0 = mirror-smooth) |
normalScale | any | — | — | Normal map intensity multiplier |
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. metallic, roughness and
occlusionStrength live in the properties object; the rest of the columns are for consumers that
read them (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 and normalScale pass through to the Unity runtime without the Engine
having to define them. A non-finite value (NaN, infinity) falls back to the default rather than
poisoning the shader.
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 and shadow distance — 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 | aces | Curve: aces (filmic shoulder), reinhard, or none (raw clipped radiance — the debug view) |
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 |
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 Graphics 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.
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.
| Property | Range | Default | Description |
|---|---|---|---|
frictionMultiplier | 0.0 – 1.0 | 1.0 | Grip a player standing on this surface experiences |
{ "name": "Ice", "textures": { "baseColor": "textures/ice.png" }, "surface": { "frictionMultiplier": 0.15 }}frictionMultiplier is the surface’s grip: it scales ground acceleration, ground friction and ground-stick together. 1.0 is full grip (an ordinary floor — the default, so a material with no surface block behaves exactly as before); 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. Values outside 0.0 – 1.0 are a compile error.
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 surface with no per-material grip (a box/hull collider, or a material with no surface block) 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 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). 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 } }Use in maps
Section titled “Use in maps”A dh.map material slot references a dh.material by barcode — a path read from the
pallet root (materials/name.dh-mat, or the same path written out as /materials/name.dh-mat),
never relative to the map file’s own folder. The Engine map
renderer honors all five texture channels (baseColor, normal, metallicRoughness,
occlusion, emissive), the metallic / roughness / occlusionStrength scalars, 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.
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}Transparent (glass, water):
{ "alphaMode": "blend", "tint": [1.0, 1.0, 1.0, 0.5]}Auto-Inference
Section titled “Auto-Inference”When alphaMode is omitted, blend mode is auto-inferred in these cases:
- Tint alpha: If
tintalpha is less than 1.0 (detected at runtime by Unity) - Texture alpha: If the
baseColortexture is adh.texturewith a non-trivial"a"pack channel (detected at compile time)
To override, explicitly set alphaMode.
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 |
When omitted, defaults to "lit".
{ "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".
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:
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.