Skip to content

dh.material

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

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

PropertyTypeRequiredDescription
$type"dh.material"noType identifier
namestringnoDisplay name
descriptionstringnoDescription
inheritsstringnoBarcode of the material to inherit from
texturesobjectnoTexture channel assignments
propertiesobjectnoFloat material properties
tintcolornoBase color multiplier (RGBA), authored in sRGB (hex = face value)
emissiveColorcolornoEmissive color, authored in sRGB in [0, 1] (default: none/black)
emissiveIntensityfloatnoLinear HDR multiplier for emissiveColor (default: 1)
uvScale[u, v] or { "u", "v" }noScale applied to texture UVs before sampling (default: [1, 1])
alphaModestringnoAlpha rendering mode
alphaCutofffloatnoCutoff threshold for "mask" mode (default: 0.5)
shaderstringnoSemantic shader ("lit" or "unlit")
renderFacestringnoFace culling ("front", "back", or "both")
surfaceobjectnoPhysics/surface (surfaceprop) block — how the material behaves underfoot

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

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

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

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

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.

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

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

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

See dh.texture for more on channel operations.

PropertyRangeDefaultEngineDescription
metallic0.0 – 1.00How metallic the surface is (0 = dielectric, 1 = conductor)
roughness0.0 – 1.01Surface roughness (1.0 = fully rough)
occlusionStrength0.0 – 1.01How strongly the occlusion map darkens ambient (0 ignores it)
smoothness0.0 – 1.0Inverse of roughness (1.0 = mirror-smooth)
normalScaleanyNormal map intensity multiplier
emissiveIntensityany1See 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 roughness or smoothness, not both. They’re inverses of each other. Converting a Unity material means writing roughness = 1 − smoothness — see Porting from Unity.

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

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

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

SettingDefaultWhat it does
world.render.exposure1Linear multiplier applied to scene radiance before the curve
world.render.tonemapacesCurve: aces (filmic shoulder), reinhard, or none (raw clipped radiance — the debug view)
world.render.bloomtrueWhether the world’s bright areas bloom
world.render.bloomIntensity0.046Linear multiplier the resolved bloom pyramid is added back with. Clamped to 04
world.render.bloomThreshold1.721Linear radiance (max channel) above which a pixel blooms. Clamped to 064
world.render.bloomSoftKnee0.5Width of the knee below the threshold, as a fraction of it. Clamped to 01
world.render.bloomDiffusion6.2How far the bloom spreads, in pyramid levels. Clamped to 110
world.render.bloomAnamorphic-0.24Aspect distortion of the glow. Clamped to -11
client.render.dithertrueAdds triangular-PDF interleaved gradient noise one 8-bit step wide, dissolving the banding smooth gradients otherwise show
client.render.sharpenfalseRCAS sharpening of the tonemapped result. Client-only, and independent of Panini
client.render.sharpenStrength0.5Strength of that sharpen over 01. 0 is an exact identity
client.render.vignettefalseVignette: darkens the frame corners on linear radiance, before the curve
client.render.vignetteIntensity0.35How dark the corners go over 01. 0 is an exact identity
client.render.vignetteSmoothness0.5Width of the vignette falloff as a fraction of the frame radius. Clamped to 0.051
client.render.chromaticAberrationfalseChromatic aberration: splits red and blue radially outward
client.render.chromaticAberrationStrength0.35How far they separate at the corners, over 01. 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:

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

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

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

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

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

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

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

The UI renders after the resolve, so the console and overlay are never tonemapped. Exposure, the tonemap curve and dither are console-only today; the 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.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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.

PropertyRangeDefaultDescription
frictionMultiplier0.0 – 1.01.0Grip a player standing on this surface experiences
ice.dh-mat
{
"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.

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).

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

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

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

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

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

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

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

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

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

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

Two formats — array or object:

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

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

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

Lit surfaces use a metallic-roughness BRDF: GGX/Trowbridge-Reitz normal distribution, Smith height-correlated visibility, Schlick Fresnel, with a Lambert diffuse lobe scaled by (1 − F) and (1 − metallic). Normal mapping is tangent-space; tangents come from the mesh’s glTF TANGENT attribute when present and are generated deterministically from UV deltas when not.

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

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

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

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

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

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

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

Cutout (foliage, hair cards):

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

Transparent (glass, water):

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

When alphaMode is omitted, blend mode is auto-inferred in these cases:

  • Tint alpha: If tint alpha is less than 1.0 (detected at runtime by Unity)
  • Texture alpha: If the baseColor texture is a dh.texture with a non-trivial "a" pack channel (detected at compile time)

To override, explicitly set alphaMode.

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

ValueDescription
"lit"Standard PBR shader with lighting (default)
"unlit"Unlit shader, no lighting calculations

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.

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

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

When omitted, defaults to "front".

Double-sided material (foliage, cloth):

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

For Unity-specific shader settings, use platform overrides with raw shader property names:

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

Unity override example:

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

The _Mode property accepts string values:

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

Numeric values (0–3) also work.