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 light entity’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 | light entity 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 |
| 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 |
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 |
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 |
|---|---|
_BumpScale (normal map intensity) | No equivalent. A bound normal map always applies at full strength. Re-author the map if it is too strong |
Secondary UVs (_UVSec), UV2 | Not authorable. MeshVertex does carry a second UV set, but it is reserved for baked lighting and is generated by the compiler, not read from the GLB — a TEXCOORD_1 in your export is ignored. No material channel can be pointed at it |
Detail maps (_DetailAlbedoMap, _DetailNormalMap, _DetailMask) | Not supported. The texture channel set is closed — a detail key is a compile error |
Height / parallax (_ParallaxMap) | Not supported, same reason |
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 | Not consumed. alphaMode, alphaCutoff and renderFace are part of the dh.material schema and are honored by the Unity runtime, but the Engine’s map renderer reads neither — every map surface renders opaque and double-sidedness is not applied. Premultiplied alpha has no representation at all |
| Fog | No fog system, of any kind |
| Screen-space ambient occlusion | Not yet. Scene depth is already stored for it, but nothing reads it. A bound occlusion map is the only AO today, and it darkens ambient only |
| Baked lightmaps | Partially. A map can bake direct light — see lighting.lightmap and "mode": "baked" on a light. The compiler generates the atlas UVs itself; Unity’s Lightmap UVs / Generate Lightmap UVs import settings have no counterpart because there is nothing to import |
| GI, light bounce, color bleeding | Not supported. The bake is direct-only — one ray from surface to light, no bounce. A surface the baked lights cannot see directly gets nothing from the bake, and ambient does all the indirect fill |
| Directional / MonoSH lightmaps, baked specular | Not yet — roadmap v1. The stored lightmap is flat diffuse irradiance with no direction, so a baked light produces no specular highlight and does not respond to normal maps |
| Emissive contributing to GI | N/A — there is no GI. Emission is a surface term added after shading, and nothing else in the scene sees it |
| Reflection probes, indirect specular, SSR | Not supported. Metals substitute their F0 into the ambient tint rather than sampling a probe, so a metal reads as a tinted hemisphere instead of a mirror |
| 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 has none yet.
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. - 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.