Skip to content

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 valueEncodingWhy
LDR authored color — picked from a swatch, in [0, 1]sRGB, linearized on loadIt is a color a human chose by eye, and eyes are not linear
HDR authored color — may exceed 1.0Linear, necessarilyThe sRGB transfer function is only defined on [0, 1]. There is no “sRGB 2.0”
Computed value — radiance, irradiance, a light’s contributionAlways linearIt 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 intensityemissiveIntensity, 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.

Values verified by matching a ported scene against its Unity original. “Direct” means copy the number across unchanged.

Unity (built-in)DigitalHeavenRule
_Color, _EmissionColor (Linear project)tint, emissiveColorDirect — both sides are sRGB-authored. Linearizing on the way in is the classic 9× sun error
Light component colorlight entity colorDirect — 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.groundStored 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_Intensityworld.sun.intensity1:1. Neither renderer divides its diffuse lobe by π, so the artist-facing scale matches
_Glossiness / smoothnessroughnessroughness = 1 − smoothness. Getting this backwards inverts every surface in the scene
_Metallicproperties.metallicDirect
_MainTex m_ScaleuvScaleDirect
Directional light euler (ZXY)world.sun.directionUnity 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 + rangeintensity + rangeNot 1:1 — see below
Trilight ambient (sky/equator/ground)world.ambient.sky + world.ambient.groundDigitalHeaven’s ambient is a two-color hemisphere with no equator band; fold the equator color into the two ends
Skybox/Procedural material + Ambient Source: Skyboxlighting.sky + ambientModeThe 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) diffusehalf-Lambert wrapDigitalHeaven wraps by default. To match Unity’s hard terminator set world.lighting.halfLambert to roughly 0.25, not 00 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/25 where t = d/range. Unity samples it from a baked lookup texture (_LightTexture0, see AutoLight.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 physical 1/d² through most of its reach and falls to exactly zero at the authored range. 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.

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.

Unity featureStatus 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), UV2Not 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_OffsetNo equivalent. Materials have uvScale only; the shader samples uv × uvScale with no offset term
_SmoothnessTextureChannel, _GlossMapScaleNot supported. Roughness comes from G of metallicRoughness (glTF packing), multiplied by the roughness scalar
maxTextureSize import clampNo equivalent. Resize explicitly with dh.texture size
Stipple/dither transparency shadersNo equivalent. A material using one renders as opaque lit PBR
Unity featureStatus in DigitalHeaven
Transparency in the Engine map rendererNot 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
FogNo fog system, of any kind
Screen-space ambient occlusionNot 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 lightmapsPartially. 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 bleedingNot 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 specularNot yetroadmap 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 GIN/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, SSRNot 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 shadowsNot supported. Cascaded shadows are sun-only; analytic lights never cast
Light cookies, halos, lens flares, area lights, IES profilesNot supported
Ambient as a full 9-coefficient SH probeNot 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 viewCapped. The frame ranks and keeps the 64 most influential; the rest are dropped for that frame

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.

EffectStateSettings
BloomonIntensity 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 OcclusiononMode Multi Scale Volumetric Obscurance, Intensity 0.82, Thickness Modifier 1.7, Color black, Ambient Only off
Chromatic AberrationonIntensity 0.05, no spectral LUT, Fast Mode off — very subtle
VignetteonClassic, black, Center (0.5, 0.5), Intensity 0.13, Smoothness 1, Roundness 1, Rounded off — subtle
Depth of FielddisabledStored anyway: Focus Distance 0.1, Aperture 32, Focal Length 300, Max Blur Size Very Large
Tonemapper / Color Grading / Auto ExposureabsentNot 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:

CurveGain at linear 0.02Dark-to-bright ratioReference
aces (Narkowicz fit)0.530.0140.058
ACES Hill RRT+ODT fit0.160.0100.058
Khronos PBR Neutral0.130.0040.058
reinhard0.980.0650.058
reinhardWhite (the default)0.980.0730.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.721

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

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%; the Y1-1 coefficient 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%.
  1. Convert every smoothness to roughness = 1 − smoothness.
  2. Set "sRGB": false on every normal, metallic-roughness and occlusion texture — the compiler will stop the build if you miss one.
  3. Repack _MetallicGlossMap into glTF ordering (roughness G, metallic B) with a dh.texture pack — read smoothness from A with the @!a invert syntax to get roughness, and metallic from R as @r.
  4. Copy _Color / _EmissionColor and Light component colors as-is — every authored color is sRGB on both sides. Only a computed linear reference (m_IndirectSpecularColor) needs encoding back to sRGB.
  5. Negate Z on the sun’s direction, and check elevation before azimuth.
  6. Split any HDR emission color into an sRGB emissiveColor plus an emissiveIntensity, and bind an emissive mask so the housing does not glow as hard as the bulb.
  7. Set world.lighting.halfLambert to 0.25 if you are matching a Unity reference screenshot, or leave it at 1 for the Source look the engine defaults to.
  8. Put the map’s point and spot lights on the built-in pipeline’s curve with "falloff": "unity" in the lighting block before touching any intensity — the curve, not the intensity, is what makes a ported lamp read wrong.
  9. Retune the lights that read wrong with lights.set live, then write the values back into the map.