Porting from Unity
Most content that reaches DigitalHeaven.Engine today arrives from a Unity scene authored against the built-in render pipeline. The two renderers agree on more than they disagree, but the places they disagree are exactly the places a port goes silently wrong — a sun nine times too bright, an ambient six times too dark, every surface inverted from smooth to rough.
This page records the conversions that are correct, and — just as important — the Unity features that have no DigitalHeaven equivalent at all, so that knowledge does not have to be rediscovered one broken material at a time.
Color space: the dividing line is dynamic range
Section titled “Color space: the dividing line is dynamic range”The single rule that resolves almost every color question is that a color’s encoding follows its dynamic range, not where it came from:
| Kind of value | Encoding | Why |
|---|---|---|
LDR authored color — picked from a swatch, in [0, 1] | sRGB, linearized on load | It is a color a human chose by eye, and eyes are not linear |
HDR authored color — may exceed 1.0 | Linear, necessarily | The sRGB transfer function is only defined on [0, 1]. There is no “sRGB 2.0” |
| Computed value — radiance, irradiance, a light’s contribution | Always linear | It was never encoded in the first place |
This is why Unity’s own HDR color picker is an LDR swatch plus a separate intensity/EV slider,
and why DigitalHeaven splits emission the same way into
emissiveColor (sRGB, [0, 1]) × emissiveIntensity (linear).
Every author-facing color in DigitalHeaven follows the first row, without exception: a material’s
tint and emissiveColor, a dh.light’s color, and the map lighting
block’s sunColor / ambientSky / ambientGround are all sRGB face values in [0, 1],
linearized exactly once on load. Every one of them is paired with a linear, unbounded intensity —
emissiveIntensity, a light’s intensity, sunIntensity, ambientIntensity — and that is the only
place an HDR value belongs. A color component above 1 is a compile error, because sRGB 1 already
is white.
Two real porting bugs came from getting this backwards in opposite directions:
- A data texture uploaded as sRGB, so every metallic/roughness texel was decoded on sample and the surface lit wrong. The compiler now rejects this outright — see data channels must be linear.
- A computed linear value treated as an authored sRGB swatch and linearized a second time, landing ambient roughly 6× too dark.
Conversions that are correct
Section titled “Conversions that are correct”Values verified by matching a ported scene against its Unity original. “Direct” means copy the number across unchanged.
| Unity (built-in) | DigitalHeaven | Rule |
|---|---|---|
_Color, _EmissionColor (Linear project) | tint, emissiveColor | Direct — both sides are sRGB-authored. Linearizing on the way in is the classic 9× sun error |
Light component color | dh.light color | Direct — Unity serializes it sRGB even in a Linear project, and DigitalHeaven’s light color is sRGB too. Linearizing on the way in is the classic 9× sun error |
m_IndirectSpecularColor (ambient reference) | world.ambient.sky / world.ambient.ground | Stored linear — it is computed, not picked — while the DigitalHeaven field is sRGB. Encode it back to sRGB (ToSrgb) on the way in, and clamp: anything past 1 belongs in ambientIntensity |
Directional light m_Intensity | world.sun.intensity | 1:1. Neither renderer divides its diffuse lobe by π, so the artist-facing scale matches |
_Glossiness / smoothness | roughness | roughness = 1 − smoothness. Getting this backwards inverts every surface in the scene |
_Metallic | properties.metallic | Direct |
_MainTex m_Scale | uvScale | Direct |
_ParallaxMap + _Parallax | textures.height + properties.heightScale | Not direct. The map must be re-declared linear ("sRGB": false), and the scale is in UV units rather than Unity’s meters: heightScale = worldDepth / worldUnitsPerUvUnit. Ported values land around 0.01 – 0.05. Unity treats mid-gray as the surface, so a ported map wants heightReference: 0.5 to look the same — 0 (the default) re-cuts the relief inward, which is usually what you want |
_BumpScale | properties.normalScale | Direct. Same meaning, same default of 1; clamped to 0–8 |
_DetailAlbedoMap m_Scale | detail.uvScale | Direct — and independent of the base uvScale, which is the whole point of the block |
| Directional light euler (ZXY) | world.sun.direction | Unity is left-handed, +Z forward; DigitalHeaven is right-handed, −Z forward — so negate Z. Elevation is invariant; only azimuth is ambiguous. sunDirection is the direction light travels, so an overhead sun is [0, -1, 0] |
Point light intensity + range | intensity + range | Not 1:1 — see below |
| Trilight ambient (sky/equator/ground) | world.ambient.sky + world.ambient.ground | DigitalHeaven’s ambient is a two-color hemisphere with no equator band; fold the equator color into the two ends |
Skybox/Procedural material + Ambient Source: Skybox | lighting.sky + ambientMode | The closest analogue, but not a parameter port: Unity’s atmosphere thickness/exposure/ground-tint have no term-by-term equivalent in DigitalHeaven’s Rayleigh + Mie model. Turn the sky on, then dial skyRayleigh / skyMie by eye. Ambient from the sky is auto’s behavior once the sky is on |
Skybox/Cubemap material (_Tex, _Exposure, _Rotation) | lighting.skyImage | A real port, unlike the procedural one. Export the cubemap as an equirectangular .exr; rotation is _Rotation in the same sense and the same degrees, and carries across unchanged. exposure is _Exposure at half the radiance — see below. _Tint has no field — fold it into the image. If ffmpeg refuses the file, it is 32-bit PIZ; re-save it as ZIP |
max(N·L, 0) diffuse | half-Lambert wrap | DigitalHeaven wraps by default. To match Unity’s hard terminator set world.lighting.halfLambert to roughly 0.25, not 0 — 0 also hardens the ambient horizon and overshoots the other way |
The image sky’s exposure and rotation
Section titled “The image sky’s exposure and rotation”Two things are worth stating outright, because a ported night sky is where both bite.
Exposure is half. Unity’s Skybox/Cubemap shader multiplies the sampled texel by
_Exposure * unity_ColorSpaceDouble, and in a linear-color-space project that constant is 2.
DigitalHeaven’s exposure is the plain multiplier it says it is, so the same number is half the
radiance Unity put on screen. Type the number Unity had and the sky is darker by a stop; type twice
it and the radiance matches exactly.
Matching radiance is not the same as matching pixels, though, and it is worth knowing which is
wanted. VRChat shows that radiance essentially raw. DigitalHeaven resolves the frame through a
tonemap curve and a bloom composite (world.render.tonemap, world.render.bloom*), which is why a
faithful port of the same numbers still does not photograph identically — the difference is the
display chain, not the sky. Dial those, rather than bending exposure until the sky is right and the
rest of the world is wrong.
Rotation needs no correction. Two reflections are in play and they cancel. A Unity world
imported through glTF is mirrored in X, because a left-handed space converted to a right-handed one
is a reflection — the same flip the sunDirection row above negates a component for. The other is
the equirectangular convention itself: DigitalHeaven reads the image’s middle column as facing
-Z, Unity reads it as facing +X, and those two centers differ by a reflection too. What is left
once they cancel is a quarter turn, which the engine’s lookup carries. So rotation is
_Rotation: the same degrees, turning the same way, landing the moon where the Unity scene had it.
Nothing to cancel by hand, and no per-map switch to get wrong.
Mochie Standard’s _Detail* block: the detail layer
Section titled “Mochie Standard’s _Detail* block: the detail layer”Unlike Filtering, this one stays on the material. Mochie’s detail block and DigitalHeaven’s
detail layer are the same idea with the same blend math — the
blend-mode enum is Mochie’s BlendColors ordinals verbatim, so a mode picked by name lands on the
identical curve:
| Mochie property | detail field | Note |
|---|---|---|
_DetailAlbedoMap | detail.textures.baseColor | |
_DetailNormalMap | detail.textures.normal | Set "sRGB": false — it is a data channel here |
_DetailRoughnessMap / detail metallic-smoothness | detail.textures.metallicRoughness | Repack to glTF ordering (roughness G, metallic B), same as the base map |
_DetailAOMap | detail.textures.occlusion | Data channel |
_DetailTint | detail.tint | sRGB, like every authored color |
_DetailAlbedoMap scale (m_Scale) | detail.uvScale | The reason the layer exists — this is a second tiling rate, independent of the base uvScale |
_DetailAlbedoBlending, _DetailRoughnessBlending, … | detail.blend.<key> | Same names, same ordinals: add, alpha, mul, mulx2, overlay, screen, lerp |
_DetailAlbedoStrength, _DetailNormalStrength, … | detail.strength.<key> | 0 – 1, folded into the blend so 0 is an exact identity |
_DetailMask, _DetailUV, packed detail | — | Not supported; see the table below |
Two things differ. Blending happens in linear light, so mulx2 scales by the linear
unity_ColorSpaceDouble (4.5948) rather than by 2. And strength and blend are keyed by
channel role, so metallic and roughness are tuned separately even though one packed map feeds
both — metallic defaults to strength 0, which is usually what you want from a detail map.
Mochie Standard’s Filtering block: bake it into the texture
Section titled “Mochie Standard’s Filtering block: bake it into the texture”Same class of problem as _MetallicGlossMap: the value lives on the material in Unity and has to
land on the texture in DigitalHeaven, because the DigitalHeaven material has no such knob.
A VRChat material built on Mochie Standard commonly carries a base-color Filtering block — a hue
shift, a saturation scale, a contrast scale and a brightness multiply, applied to the sampled albedo
every frame. Two material variants sharing one texture are, very often, only these four floats apart.
DigitalHeaven has no per-material color filter, so the grade moves to the texture and happens once, at
build, through dh.texture grade:
| Mochie property | grade field | Note |
|---|---|---|
_Hue | hue | Additive in HSV, a fraction of the wheel. 0 and 1 both mean “no shift” |
_Saturation | saturation | Identity is 1, not 0 |
_Contrast | contrast | Identity is 1. Pivots on 0.5 |
_Brightness | brightness | Identity is 1. Plain multiply |
_HueMode | — | Only the HSV mode (0) is modeled; the shader’s Oklab mode has no field |
_MonoTint | — | No equivalent |
The order (hue → saturation → contrast → brightness), the Rec.601-ish luma weights saturation
desaturates against, and the fact that nothing is clamped between the steps are all preserved, so the
numbers transfer verbatim — copy the four floats out of the material and into the .dh-tex. The
grade runs in linear light, which is what the shader was doing on the sampled texel, so do not
pre-convert anything.
The one thing that does not carry over is liveness: a Mochie filter is a material slider you can
drag, and a grade is baked at dh build. Re-tuning means editing the .dh-tex and rebuilding — the
source image is never modified, so the values stay as adjustable as the build loop is fast.
If two Unity materials differ only by their Filtering block, they become two .dh-tex definitions
over one source image, not two copies of the texture.
Bakery lights
Section titled “Bakery lights”A scene baked with Bakery ports its baked lights with no unit conversion; what does not port is Bakery’s per-light bounce multiplier.
| Bakery | DigitalHeaven | Rule |
|---|---|---|
BakeryPointLight with the legacy falloff (realisticFalloff off) | a baked light with falloff: "unity" | 1:1. Bakery’s legacy curve is its imitation of the built-in pipeline’s (it scales distance by 5 / cutoff), which "unity" evaluates. cutoff is range; intensity is intensity; color is sRGB on both sides and both multiply its linear value by the intensity. Neither divides by π |
BakeryLightMesh | a part override’s bakeEmission | 1:1. Both take color × intensity as the surface’s radiance in the same units as a material’s emission, so a mesh of emission 1 lights a white wall around it to 1. The node’s material emissive texture masks the override; a Bakery light mesh uses its own texture, uniform when it has none, so give such a node an untextured material or none |
BakeryDirectLight intensity | sunIntensity | 1:1, as the directional row above |
Any Bakery light’s indirectIntensity | none | Not ported. Bakery multiplies the light it bounces by this, a non-physical boost; DigitalHeaven bounces every light at 1. MLTN City’s street lamps and string lights bounce at 3, its light meshes at 1.5 to 12 and its sun at 1.5, which is why the ground and walls around a pool read dimmer and cooler in DigitalHeaven than the pool itself |
Point and spot falloff: switch the curve, do not convert the intensity
Section titled “Point and spot falloff: switch the curve, do not convert the intensity”The two engines use different attenuation curves, and there is no single conversion factor
between them — an “equivalent” intensity would depend on the distance you happened to match at.
- Unity’s legacy built-in point falloff is
(1/(1 + 25t²) − 1/26) · 26/25wheret = d/range. Unity samples it from a baked lookup texture (_LightTexture0, seeAutoLight.cginc) rather than evaluating it, but that is the curve the texture holds. It is bounded at 1 at the bulb. - DigitalHeaven’s default is a windowed inverse-square,
(1 − (d/r)⁴)² / d², which reads as physical1/d²through most of its reach and falls to exactly zero at the authoredrange. It is unbounded near the bulb. It is also, exactly, the curve Unity’s own URP, HDRP and lightmapper use — so a port from those needs nothing here.
So rather than converting, adopt the curve. Setting
lighting.falloff to "unity" — or just naming
"look": "unity", which carries it — puts the whole map on
the built-in pipeline’s curve, and your authored intensities transfer directly. Individual fixtures
can still opt back out with a per-light falloff.
This is worth doing early: under the physical curve a scene’s lamps read as a row of blown-out white discs with dark gaps between them, which is easy to misdiagnose as an exposure or bloom problem and chase in the wrong block.
What has no DigitalHeaven equivalent
Section titled “What has no DigitalHeaven equivalent”Recorded deliberately. A feature listed here is not an oversight in this page — it genuinely does not exist in the engine, and content relying on it needs to be re-authored rather than converted.
Material and texture authoring
Section titled “Material and texture authoring”| Unity feature | Status in DigitalHeaven |
|---|---|
Secondary UVs (_UVSec), UV2 | Not selectable. MeshVertex carries TEXCOORD_0 through TEXCOORD_3 verbatim, so a second unwrap in your export survives the import intact — but no material channel can be pointed at one yet: every channel samples UV0. Baked lighting does not use them either; its coordinate is a separate unwrap the bake makes and stores with the lightmap |
_DetailMask | Not supported. The detail layer applies over the whole surface; there is no mask texture gating where |
Texture m_Offset | No equivalent. Materials have uvScale only; the shader samples uv × uvScale with no offset term |
_SmoothnessTextureChannel, _GlossMapScale | Not supported. Roughness comes from G of metallicRoughness (glTF packing), multiplied by the roughness scalar |
maxTextureSize import clamp | No equivalent. Resize explicitly with dh.texture size |
| Stipple/dither transparency shaders | No equivalent. A material using one renders as opaque lit PBR |
Rendering and lighting
Section titled “Rendering and lighting”| Unity feature | Status in DigitalHeaven |
|---|---|
| Transparency in the Engine map renderer | Cutouts and blending. alphaMode: "mask" + alphaCutoff discard a texel below the cutoff and stay an opaque draw; "blend" — and an unauthored mode with a tint alpha below one — draws in a sorted transparent pass after the opaques with depth write off, and is skipped by the shadow, occlusion and outline prepasses. Unity and the Engine share one classification, so they cannot disagree. renderFace is still Unity-only: double-sidedness is not applied. Premultiplied alpha has no representation — Unity’s "transparent" blends as straight source-alpha here |
| Fog | No fog system, of any kind |
| Screen-space ambient occlusion | GTAO, off by default (client.render.ao). It darkens ambient only, combined with a bound occlusion map by min; see ambient occlusion |
| Baked lightmaps | Supported. A map bakes in the editor on the GPU (Render Bake) into a sidecar beside its source; see lighting.lightmap. The bake unwraps the atlas itself with xatlas, so Unity’s Generate Lightmap UVs has no counterpart. A node’s lightmapScale is Unity’s Scale In Lightmap, and bake zones set a texel density per hierarchy subtree |
| GI, light bounce, color bleeding | Supported. The bake runs bounces passes (three by default) that reflect each surface’s textured base color, and a light probe volume carries the baked light to dynamic objects |
| Directional / MonoSH lightmaps, baked specular | Supported. The atlas stores Bakery’s MonoSH, evaluated non-linearly against the per-pixel normal, so normal maps respond to baked light, and a baked highlight is shaded from its dominant direction |
| Emissive contributing to GI | Supported. A material’s bakeEmission, or a part override’s bakeEmission, makes a surface a light in the bake; see emissive surfaces |
| Reflection probes, indirect specular | Supported. A dh.reflectionProbe box captures a cubemap and prefilters it into a roughness chain the shader reads by lobe width |
| Point and spot shadows | Not supported. Cascaded shadows are sun-only; analytic lights never cast |
| Light cookies, halos, lens flares, area lights, IES profiles | Not supported |
| Ambient as a full 9-coefficient SH probe | Not supported. Ambient is a hemisphere (an L0 term plus one L1 lobe along Y), which loses the sun-ward directionality a real SH probe carries |
| More than 64 analytic lights in view | Capped. The frame ranks and keeps the 64 most influential; the rest are dropped for that frame |
Post-processing reference profile
Section titled “Post-processing reference profile”DigitalHeaven’s post stack is the HDR resolve (Panini resample → exposure → bloom composite → tonemap curve → dither), the bloom pyramid that feeds it, and the optional Panini projection. Bloom has shipped; ambient occlusion, chromatic aberration, vignette and depth of field have not. The values below are recorded because they are the reference point DigitalHeaven’s post stack is calibrated against — a Unity PPv2 global volume (weight 1, priority 0, global) used across a whole ported scene set.
The comparison is closer than it first looks, because DigitalHeaven’s look is world-authored: a
map’s render block seeds the replicated world.render.exposure,
world.render.tonemap and the six world.render.bloom* values, so the exposure, the curve and the
glow are a property of the scene the way a global Volume is, not a per-player display preference.
A player’s client.render.* override is unset by default and simply follows the world. Anything a
post stack adds that is genuinely art direction belongs on the same world-authored side; the comfort
and cost knobs (FOV,
Panini, dither, shadow resolution and distance) stay
client-only.
| Effect | State | Settings |
|---|---|---|
| Bloom | on | Intensity 0.65, Threshold 1.28, Soft Knee 0.5, Clamp 65472 (≈ fp16 max), Diffusion 6.2, Anamorphic Ratio −0.24 (negative = vertical stretch), Color HDR white, Fast Mode on. A lens-dirt texture is present but its override is unchecked, so it is disabled |
| Ambient Occlusion | on | Mode Multi Scale Volumetric Obscurance, Intensity 0.82, Thickness Modifier 1.7, Color black, Ambient Only off |
| Chromatic Aberration | on | Intensity 0.05, no spectral LUT, Fast Mode off — very subtle |
| Vignette | on | Classic, black, Center (0.5, 0.5), Intensity 0.13, Smoothness 1, Roundness 1, Rounded off — subtle |
| Depth of Field | disabled | Stored anyway: Focus Distance 0.1, Aperture 32, Focal Length 300, Max Blur Size Very Large |
| Tonemapper / Color Grading / Auto Exposure | absent | Not merely disabled — there is no ColorGrading, Tonemapping or AutoExposure override in the profile at all. The reference writes linear radiance straight to sRGB with no curve |
A bloom threshold of 1.28 is only meaningful against an HDR target. Before the engine gained its
offscreen half-float target every value clipped at 1.0, so this profile could not have been
reproduced at all; it can now.
The tonemap curve is the largest single divergence
Section titled “The tonemap curve is the largest single divergence”The reference applies no tone curve whatsoever, and DigitalHeaven cannot follow it there — a
renderer with no curve clips every value above 1.0 flat, which is the thing the HDR target exists to
avoid. So a curve is applied, and which curve is the single biggest lever on whether a ported scene
matches its reference.
It is not a brightness question, which is the trap. Measure the ratio between a dark surface and a bright one in the same frame, and compare it against the reference:
| Curve | Gain at linear 0.02 | Dark-to-bright ratio | Reference |
|---|---|---|---|
aces (Narkowicz fit) | 0.53 | 0.014 | 0.058 |
| ACES Hill RRT+ODT fit | 0.16 | 0.010 | 0.058 |
| Khronos PBR Neutral | 0.13 | 0.004 | 0.058 |
reinhard | 0.98 | 0.065 | 0.058 |
reinhardWhite (the default) | 0.98 | 0.073 | 0.058 |
ACES lands roughly 4x tighter than the reference, and no exposure value fixes it — exposure
scales the input, it does not change the curve’s shape. The cause is the toe: aces(x) = x holds only
at x ≈ 0.0622 and x ≈ 0.728, so shadows below the first crossover are darkened while midtones
above it are lifted, squeezing the two together. Making the ACES fit more accurate makes it worse,
and Khronos PBR Neutral — despite being designed to preserve material appearance — is worst of all,
because it subtracts a black-point offset before its identity region.
The curves that hold the relationship are the ones with no toe, and reinhardWhite is the default
for that reason. It errs the other way: shadows land somewhat brighter relative to midtones than the
reference, which is the safe direction, and it is partly justified anyway — the reference ran ambient
occlusion at 0.82 darkening exactly those shadows, and DigitalHeaven’s own is off by default.
Because reinhardWhite still compresses midtones (gain ≈ 0.88 by linear 0.14), a faithful port
keeps the reference’s own lighting values and puts the correction in exposure. For the night
city scene that is Unity’s m_AmbientIntensity of 1.0 carried across unchanged with
render.exposure at 2.0 — which reproduces the reference’s midtone luminance to within a couple of
8-bit steps.
How DigitalHeaven’s bloom lines up against that profile
Section titled “How DigitalHeaven’s bloom lines up against that profile”The soft knee (0.5) and anamorphic ratio (−0.24) carry over as-is: they are authored in the same
units and the base extent and level count are derived the same way, so −0.24 is the same gentle
vertical stretch. Intensity does not transfer numerically: DigitalHeaven’s tent sums every rung on
the way up without renormalizing, so the useful range is far smaller than PPv2’s. Diffusion did not
survive tuning either — the reference’s 6.2 produced a glow too tight to read against a linear HDR
scene, and the shipped default is 10, the top of the range. Note that 10 sits at the pyramid’s
level cap, so above roughly 9 the depth is decided by the cap rather than the authored number and
the glow’s screen fraction varies mildly with resolution. Three further things deliberately do not
carry over.
The threshold is authored in linear. PPv2 takes a threshold in gamma and converts it
internally; DigitalHeaven takes world.render.bloomThreshold in linear radiance and converts
nothing, because the engine is linear end to end. Carrying Unity’s internal conversion into a linear
engine would be a compatibility quirk, not a feature — so the conversion is done once, in choosing
the default:
1.28^2.2 = 1.7213 → world.render.bloomThreshold = 1.721That is a plain 2.2 power, not the sRGB transfer function, because Unity’s
Mathf.GammaToLinearSpace is piecewise. The editor install spells all three branches out as
GammaToLinearSpaceExact in CGIncludes/UnityCG.cginc:
if (value <= 0.04045F) return value / 12.92F;else if (value < 1.0F) return pow((value + 0.055F)/1.055F, 2.4F);else return pow(value, 2.2F);The authored 1.28 is at or above 1.0, so it takes the third branch. The sRGB curve is the
second branch and applies only below 1.0; extrapolating it past 1.0 gives 1.7591, which is
simply the wrong branch for this value.
The profile’s own Clamp default corroborates the branch. 65472 goes through the same function
and lands on the same branch, and 65472^2.2 = 3.938 × 10¹⁰ — matching the ≈ 3.9 × 10¹⁰ figure the
Clamp note further down cites. The sRGB branch would put it near 3.182 × 10¹¹, which does not match
anything. Do not “fix” the default onto the sRGB curve: 1.721 linear is the reference profile’s
1.28.
The Karis average is an addition. PPv2 has no firefly suppression on its prefilter;
DigitalHeaven’s first downsample weights each sub-group by 1 / (1 + luma),
which is what keeps a moving specular glint from surviving the pyramid as a crawling blob. The
reference profile flickers where DigitalHeaven does not.
Fast Mode is off by default. The reference profile enables PPv2’s Fast Mode; DigitalHeaven
defaults to the 13-tap box and 9-tap tent, because quality is the default everywhere in this engine.
The cheap filters are still one preference away — client.render.bloomFast — and they are a client
setting, since filter cost belongs to whoever owns the GPU rather than to the map author.
There is also one PPv2 knob DigitalHeaven deliberately does not expose: Clamp. PPv2 runs that
value through its gamma-to-linear conversion too, and 65472 — nominally “the fp16 maximum” — comes
out around 3.9 × 10¹⁰, so the clamp never fires on any value a half-float target can even hold. It
is a knob that does nothing. DigitalHeaven instead clamps unconditionally to 65504, the real largest
finite half-float, in the prefilter and again at the composite, so a runaway emissive cannot push an
infinity into the pyramid where one tap would poison a whole screen of blur. That is an omission on
purpose, not a missing feature.
Research: procedural sky
Section titled “Research: procedural sky”Not implemented. Recorded so the analysis is not repeated.
- Unity’s built-in shaders are MIT licensed, so a verbatim port is legally clean with the notice retained. The URP and HDRP packages are Unity Companion License and forbid use in developing a competing product — do not read the URP copy of a shader when porting one.
- The built-in procedural sky is O’Neil single-scattering (GPU Gems 2) with two samples, evaluated per-vertex on a skybox cube. DigitalHeaven can afford per-fragment and get a better horizon.
- The sun’s color touches only the sun disc. Sky and ground scattering are driven by light direction and a hardcoded brightness constant, so dimming the sun does not dim the sky. Any port that lets light color into the scattering term will collapse ambient to black at dusk.
- Unity projects the sky to a 9-coefficient L2 SH probe, recomputed when the skybox or ambient
changes (not per frame), with the cosine convolution and basis constants folded into 7 packed
vec4s so the shader evaluation is four dot products. - Validation targets for a DigitalHeaven implementation, taken from the reference:
average sky radiance (SH L0 × 0.282095) = linear
(0.171, 0.214, 0.298)±5%; theY1-1coefficient must be(−0.018, +0.127, +0.394)— sign-positive and blue-weighted, because getting its sign wrong inverts the ambient gradient; and scaling the sun color to near-black must leave ambient unchanged to within 0.1%.
A porting checklist
Section titled “A porting checklist”- Convert every smoothness to
roughness = 1 − smoothness. - Set
"sRGB": falseon every normal, metallic-roughness and occlusion texture — the compiler will stop the build if you miss one. - Repack
_MetallicGlossMapinto glTF ordering (roughness G, metallic B) with adh.texturepack— read smoothness from A with the@!ainvert syntax to get roughness, and metallic from R as@r. - Move any Mochie Filtering block off the material and onto the texture as a
dh.texturegrade— the four floats copy across unchanged, and two materials that differ only by their filter become two.dh-texfiles over one source image. - Copy
_Color/_EmissionColorandLightcomponent colors as-is — every authored color is sRGB on both sides. Only a computed linear reference (m_IndirectSpecularColor) needs encoding back to sRGB. - Negate Z on the sun’s direction, and check elevation before azimuth.
- Split any HDR emission color into an sRGB
emissiveColorplus anemissiveIntensity, and bind anemissivemask so the housing does not glow as hard as the bulb. - Set
world.lighting.halfLambertto0.25if you are matching a Unity reference screenshot, or leave it at1for the Source look the engine defaults to. - Put the map’s point and spot lights on the built-in pipeline’s curve with
"falloff": "unity"in thelightingblock before touching any intensity — the curve, not the intensity, is what makes a ported lamp read wrong. - Retune the lights that read wrong with
lights.setlive, then write the values back into the map.