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

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 colordh.light 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
_ParallaxMap + _Parallaxtextures.height + properties.heightScaleNot 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
_BumpScaleproperties.normalScaleDirect. Same meaning, same default of 1; clamped to 0–8
_DetailAlbedoMap m_Scaledetail.uvScaleDirect — and independent of the base uvScale, which is the whole point of the block
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
Skybox/Cubemap material (_Tex, _Exposure, _Rotation)lighting.skyImageA 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) diffusehalf-Lambert wrapDigitalHeaven 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

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 propertydetail fieldNote
_DetailAlbedoMapdetail.textures.baseColor
_DetailNormalMapdetail.textures.normalSet "sRGB": false — it is a data channel here
_DetailRoughnessMap / detail metallic-smoothnessdetail.textures.metallicRoughnessRepack to glTF ordering (roughness G, metallic B), same as the base map
_DetailAOMapdetail.textures.occlusionData channel
_DetailTintdetail.tintsRGB, like every authored color
_DetailAlbedoMap scale (m_Scale)detail.uvScaleThe 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 propertygrade fieldNote
_HuehueAdditive in HSV, a fraction of the wheel. 0 and 1 both mean “no shift”
_SaturationsaturationIdentity is 1, not 0
_ContrastcontrastIdentity is 1. Pivots on 0.5
_BrightnessbrightnessIdentity 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.

A scene baked with Bakery ports its baked lights with no unit conversion; what does not port is Bakery’s per-light bounce multiplier.

BakeryDigitalHeavenRule
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 π
BakeryLightMesha part override’s bakeEmission1: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 intensitysunIntensity1:1, as the directional row above
Any Bakery light’s indirectIntensitynoneNot 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/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
Secondary UVs (_UVSec), UV2Not 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
_DetailMaskNot supported. The detail layer applies over the whole surface; there is no mask texture gating where
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 rendererCutouts 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
FogNo fog system, of any kind
Screen-space ambient occlusionGTAO, off by default (client.render.ao). It darkens ambient only, combined with a bound occlusion map by min; see ambient occlusion
Baked lightmapsSupported. 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 bleedingSupported. 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 specularSupported. 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 GISupported. A material’s bakeEmission, or a part override’s bakeEmission, makes a surface a light in the bake; see emissive surfaces
Reflection probes, indirect specularSupported. A dh.reflectionProbe box captures a cubemap and prefilters it into a roughness chain the shader reads by lobe width
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’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.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. Move any Mochie Filtering block off the material and onto the texture as a dh.texture grade — the four floats copy across unchanged, and two materials that differ only by their filter become two .dh-tex files over one source image.
  5. 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.
  6. Negate Z on the sun’s direction, and check elevation before azimuth.
  7. 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.
  8. 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.
  9. 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.
  10. Retune the lights that read wrong with lights.set live, then write the values back into the map.