Second Chance GazetteDevlogToken Use

River water renders black in Unreal Engine 5.8 and changing Absorption does nothing

unreal-enginewatersingle-layer-watermaterialsretargetingskeletal-meshlandscapesilent-failureriver

Every blade of grass, a third of the way to straw

The grass in Second Chance had been the wrong colour for as long as anyone had looked at it. Not dramatically wrong – olive, dusty, a shade that would be right for the end of August and is not right for a Midlands riverbank the rest of the year. James had a set of reference photographs of English grass and the game did not match them.

The agent – Claude Code Opus 5, which does most of the driving here – went looking for what set the colour, and found a scalar called GrassDryness running from 0 for lush rain-fed green to 1 for late-August straw. The build script that created the grass material carried a comment beside it saying the weather manager drove the value.

The weather manager does not drive the value. Read out of the graph rather than taken on trust, the weather tick writes exactly three things – rain intensity, wind speed and snow accumulation – and nothing anywhere in the project writes GrassDryness at all. So the number the player sees is the asset default, permanently, in every season and every weather. The default was 0.35. Every blade of grass in the game had been a third of the way to straw since the day it was authored.

The corroboration came free and was better than the first finding. The golf-course capture script sets the value by hand and puts it back afterwards, which is not something you write if anything else is driving it.

It went to 0.12. That is a one-constant change to a default and it fixes what is on screen; it does not build the missing seasonal response, and the entry for that is still open. There is a second one of these already on the list – eight wind parameter collections that nothing writes to either. A comment in a build script saying X drives this is now treated as unverified until somebody reads the graph.

Three millimetres, then twenty-three metres

The cat was a better problem, and it had been going for a fortnight.

Second Chance swapped its hand-made Blender cat for a bought rig back in August. In the editor viewport it looked right – correct size, correct coat, walking about. In the game it was not there. Not fallen through the floor, not streamed out, not hidden: a census had the actor present and moving. It simply did not draw.

Hours went into measuring it, and the measurements contradicted each other. Some cats read 22 to 32 centimetres. Others read three millimetres. The same cat read differently between calls.

A black and white cat with a red collar walking across the tarmac of a red-brick terraced street, with a green wheelie bin to the left and houses, a tree and a parked bike beyond.
The cat renders. The green wheelie bin behind it is a separate problem and is on the list.

The reason is worth the whole afternoon it cost. Skeletal meshes in Unreal have a tick option called ONLY_TICK_POSE_WHEN_RENDERED, and it does what it says: a mesh nobody can see does not evaluate its pose. So every socket position and every bounds figure read off a cat that was not rendering came back stale or degenerate – and it came back looking exactly like data. The instrument was reporting the symptom it was being used to diagnose.

Once the live components were forced to tick their poses regardless, all eight cats became measurable at once, and the fault turned out to be two faults that fail differently on their own.

The first was bone translation retargeting, set to Animation on all 33 bones of the new skeleton where it needed to be Skeleton. That means the bone translations came through verbatim from clips authored against the old cat's proportions, and the hierarchy folded up: head and back foot three millimetres apart. A sub-centimetre sliver of cat, drawn correctly, at a size no camera would ever resolve.

The second was scale. The bought mesh is authored in metres, so its top bone carries a scale of 100 in the reference pose, and the retarget had baked a 1 onto it in all eleven clips. The instant a clip played, the cat dropped to a hundredth of its size.

The agent found the scale half first, fixed it, and produced a twenty-three-metre cat. The actor bounds came back 1,089 by 171 by 2,335 centimetres and a camera four metres away rendered nothing at all, because it was inside the animal. That got reverted the same hour, and the revert was the right call on the evidence and the wrong conclusion – the scale fix was correct, it was just being applied on top of translations that were still wrong. Fix both and the cat is 39.5 centimetres and walking down Radworth Road with its collar on straight.

Rockstar have people who do nothing but character rigs. He had a Thursday evening.

Straight down at the water

Then the river, which took the rest of the night.

The Oakhaven is a Midlands river and it was rendering near-black. Not blue, not brown – black, with a hard bright sun glare sitting on top of it. The water body carries two parameters that are supposed to decide what colour it is, Absorption and Scattering, and every attempt to change either of them did nothing.

The test that ought to have settled it was this: Scattering pushed to (2.0, 0.02, 0.02, 4.0), which is a single-scattering albedo of 0.98 on red. Water that scatters 98 per cent of the red light hitting it is a glass of tomato juice. The frame moved by one unit. The same values pushed straight onto the live material instance, bypassing everything upstream of it, moved it by nothing.

The reason those tests were worthless is the most useful thing in this entry, and it is not about water parameters at all. It is about where the camera was.

Water gets more mirror-like the more side-on you look at it. Look down into a pond and you see the bottom; look along it and you see the sky. That is Fresnel, and at a grazing angle a water surface reflects almost everything. The eye-height view along the river – the natural one, the one a player walking the bank actually gets – is a grazing view. It is very nearly a pure mirror, and it is almost blind to whatever colour the water itself has been given.

So the A/B rig got three more angles. About twenty degrees down, for how the far half of the channel reads to somebody on the bank. About forty-five, for body colour and reflection in rough balance. And a nadir frame: nadir is the direction pointing straight below you, the opposite of the zenith overhead, and a nadir frame is a shot taken looking almost vertically down at the surface. At that angle the Fresnel reflection is at its weakest and what is left is the water's own colour.

A near-vertical view straight down at a river channel running between two green grass banks. The water fills the middle of the frame and is almost black, with a faint rippled highlight at the top.
A nadir frame at the hero reach with the shipped values. Fresnel is at its weakest here and this is what is left - which is very nearly nothing.

That is the frame the tuning gets judged on. The other three say how it will read to a player; the nadir one says what is actually in the water. Every null result of the previous session had been read off an eye-height frame, which is the one view guaranteed not to answer the question.

One divided by the number you wrote

With a frame that could see the water, the answer took about ten minutes and it was embarrassing.

The master water material was exported and read rather than reasoned about, and the wiring into the Single Layer Water output says:

AbsorptionCoefficients = (1.0 / Absorption.RGB) / Absorption.A

It is a reciprocal. Absorption.RGB multiplied by Absorption.A is not a coefficient at all – it is a mean free path, the distance light travels through the water before it is swallowed, in centimetres. Bigger means clearer.

The shipped values were (0.25, 0.35, 0.90, 8.0). That is light paths of 2.0, 2.8 and 7.2 centimetres. The Oakhaven was opaque within an inch of its own surface. There was no transmitted light to tint, which is why absorption did nothing, why scattering did nothing, why the bed was invisible and why the shallows read as dark as the channel. The only thing on screen was sky, reflected off a surface with a roughness of 0.12.

Two stacked frames of the same river reach. In the upper one the water is near-black with a bright sun glare on it and the far bank reflected in it. In the lower one the same water reads as flat muddy brown with no reflection at all.
The same reach twice. Above, the shipped water - and the navy is not the water, it is the sky mirrored at roughness 0.12. Below, roughness at 0.95 with the water parameters untouched: the mirror comes off and the river underneath is muddy brown.

And the strongest single piece of evidence for the previous, wrong diagnosis had been a test that did the exact opposite of what it claimed. Absorption had been driven to 0.001 to make the water clear. One divided by 0.001 is 1,000. That test made the water as opaque as it is possible to make it, reported no change, and was written down as proof that the parameters were orphaned.

For scale: the engine's own default is (10, 150, 350, 8), which is 0.8 metres of red, 12 of green and 28 of blue – red swallowed first, blue last, which is why deep water is blue. Epic's own river material ships sub-centimetre values, opaque on purpose. Neither of those is a Midlands river in flood season.

Fifteen units out of nowhere

One scare before it was over. Two nadir frames shot four minutes apart, with the water parameters identical between them, measured R29 G24 B24 and R13 G12 B17. A fifteen-unit swing from changing nothing at all.

Actors tick in the Unreal editor whether or not the game is running, which means a sun and a cloud rig can move between two captures and an A/B taken across minutes measures the weather instead of the change. So a probe went in to print the sun's rotation and the clock beside every capture – on the assumption that the sun had drifted.

The sun had not drifted. It read pitch −45.0000 and yaw 43.7300, identical two and a half minutes apart. What was moving was the water surface itself, which animates, and the wind in the foliage. Which means a per-pixel difference between two frames is useless and a mean over a calm patch of channel is fine: two control frames taken that way agreed to 0.4 units. The instrument was trustworthy after all, and now it prints its own conditions, which it should have done from the start.

Algae, sewage, and the one that got signed off

Twenty-three eye-height reference shots of the Trent went into the river reference folder at about twenty past eight that evening, and the colour was judged against them rather than against anybody's idea of what a river looks like.

The setting that got signed off is called silt: mean free paths of 0.85, 0.85 and 0.55 metres, scattering of 0.075, 0.085 and 0.06 per metre. Red and green equal with blue suppressed but not killed. Killing blue outright gives mustard. Letting red dominate gives flood-brown. Leaving blue level with the other two gives a swimming pool, which is roughly what most open-world games do with inland water and is the single laziest thing in the genre. GTA 6 is set in Florida and can have whatever colour water it likes. This is a river that runs past a terrace of council houses and a retail park, and it wants to look like one.

A river seen from above the bank in daylight, the water a murky olive-brown with a sun glare on the surface, reeds standing along both margins, a road and railings on the left and low hills behind.
The olive-brown the Oakhaven settled on, judged against the reference set rather than against an idea of what a river looks like.

Three more presets came out of the tuning by accident and were kept because James wanted them. algae is a near-clear column under a heavy green bloom, for if the river ever has an outbreak. peasoup is the same olive at double strength, for after heavy rain. And sewage – found while overshooting on red – is a thick orange-brown that reads as a flood or a leak, and is a story beat waiting for a story.

The same river reach with the water an opaque orange-brown, like a river in flood, reeds along both margins and the same road and railings on the left.
The sewage preset, found by overshooting on red and kept. It is not the river's everyday colour and is not meant to be.

The water level also came down 20 centimetres across all 381 spline points, in two passes, because it looked like it was about to break its banks. It still does, and that is not a water height problem: measured across the channel, the right bank has a proper berm and the left bank has a low shelf that never reaches a lip inside 25 metres. Any level that fills the channel sheets across the shelf. Getting the water back inside the real banks needs about another 50 centimetres, and 50 centimetres leaves all 3,174 reeds standing on dry land.

James looked at that and stopped rather than acting on it. It was gone one in the morning.

The fault

River water renders black in Unreal Engine 5.8 and changing Absorption or Scattering does nothing

A Single Layer Water body reads near-black with a hard sun glare on it. The bed is invisible, the shallows are as dark as the channel, and editing Absorption or Scattering moves the frame by one or two units. Scattering at a single-scattering albedo of 0.98 on red produces no red. The same values pushed onto the live material instance, bypassing the chain, also produce nothing.

Two causes, and they mask each other:

  • Absorption is a reciprocal. The shader computes

AbsorptionCoefficients = (1.0 / Absorption.RGB) / Absorption.A, so RGB × A is a mean free path in centimetres and bigger is clearer. Shipped (0.25, 0.35, 0.90, 8.0) is light paths of 2.0, 2.8 and 7.2 cm: opaque within an inch, with no transmitted light for either parameter to tint. Driving Absorption to 0.001 to "clear the water" makes it maximally opaque (1/0.001 = 1000), and that is the test most likely to be misread as proof the parameters are dead.

  • The A/B was judged at eye height, a grazing view. At grazing incidence Fresnel

approaches 1.0, so the frame is nearly pure reflected sky. The navy is the sky at a surface roughness of 0.12, not the water.

The fix

Judge water colour on a nadir frame, and read the master graph before reasoning about its parameters

Capture the same stretch at four fixed angles: eye height, about 20° down, about 45° down, and a nadir frame at about 85° down. Tune against the nadir frame, the only one where Fresnel is weak enough to show the water's own colour, and use the 20° frame to check how it reads to a player on the bank.

Settled values for a silty English river, as mean free path in metres so they read directly: 0.85, 0.85, 0.55 m, scattering 0.075, 0.085, 0.06 per metre. Red and green equal, blue suppressed but not killed.

The guards:

  • Export and read the master material graph before reasoning about its parameters. The

arithmetic here was inverted for a whole session because nobody had.

  • Water Roughness 0.12 → 0.95 takes the mirror off in one edit, the fastest way to see what

is underneath it.

  • Print the sun rotation and the clock beside every capture. Actors tick in the editor without

the game running, so an A/B taken minutes apart can measure the weather.

  • ☠ Editor Python calling into WaterBodyComponent crashed the editor twice with

EXCEPTION_ACCESS_VIOLATION. Write the material instance as an ordinary asset and let it propagate; commit spline edits with update_spline() and do not call the rebuild.

The fault

A retargeted skeletal mesh does not render in PIE, and every measurement of it reads back wrong

The actor exists, moves and is not hidden. It just never draws. Measurements disagree between calls: some bounds read plausibly, some read three millimetres, the same actor reads differently twice running.

  • The measurements are the first fault. ONLY_TICK_POSE_WHEN_RENDERED means a mesh that is not

rendering does not evaluate its pose, so socket and bounds reads come back stale or degenerate and look exactly like real data.

  • The render fault is two settings. Bone translation retargeting left on Animation

instead of Skeleton brings translations through verbatim from clips authored for a differently proportioned rig, and the hierarchy collapses: head to back foot 3 mm. And a donor mesh authored in metres carries a root scale of 100 in its reference pose, which the retarget can bake back to 1 in every clip.

  • Either alone fails, and differently. Translations only ⇒ still collapsed. Scale only

⇒ a 23-metre animal (bounds 1,089 × 171 × 2,335 cm), and a camera 4 m away renders nothing because it is inside the mesh.

The fix

Force the pose to tick before measuring, then fix translation retargeting and root scale together

Force ALWAYS_TICK_POSE_AND_REFRESH_BONES on the live components before taking a single measurement. It is transient, discarded when play stops, and it made all eight animals measurable at once after hours of contradictory readings.

Then set bone translation retargeting to Skeleton across the whole hierarchy (a recursive call from the root bone), and remove the root scale track from each clip so the bone falls back to the reference pose.

The guards:

  • Check translation retargeting on every future donor rig. It was harmless before only

because the old rig and its clips shared proportions.

  • Never measure scale from an auto-framed preview capture: a 0.3 cm animal and a correct one

render identically. Pass an explicit camera and A/B against a known-good pose.

  • A CDO cannot see Blueprint-added components, so a readback through the class default object

reports them missing when they are present on disk.

The fault

Landscape grass renders the same dry olive in every season and no weather changes it

The grass material's dryness scalar runs 0 (lush) to 1 (straw), and the build script that authored it carries a comment saying the weather manager drives it. It does not. The weather tick writes rain intensity, wind speed and snow accumulation, and nothing in the project writes the dryness value at all – so the asset default is what the player sees, permanently. The default was 0.35, i.e. every blade in the game a third of the way to late-August straw.

The fix

Correct the default, and treat "X drives this" comments in build scripts as unverified

Lower the default to 0.12: one constant, re-runnable, writes a single asset, touches zero landscape proxy packages and causes no shader recompile, because changing a scalar default does not alter shader code.

The guard: this project now has two recorded cases of an intention written into a build script as though it were built (grass dryness, and eight wind parameter collections nothing writes to). Read the graph. The corroborating tell is cheap: the golf capture script sets the dryness by hand and restores it afterwards, which nobody writes if something else is driving it.

☠ The default change stops a bad static value. It does not build the missing seasonal response, and that stays open.

The kit

ToolWhy this one
Unreal Engine 5.8where all three faults lived and the only place any of them was visible
A material graph exportsettled in ten minutes an argument that had run a whole session on inference
A four-angle capture rigthe eye-height frame cannot see the water; the nadir one can
Twenty-three reference shots of the Trentthe colour was judged against a real river, not a remembered one

Plain English

TermWhat it means
nadir frameA test shot taken looking almost straight down at a surface. Nadir is the direction pointing directly below you — the opposite of the zenith overhead. It matters for water because a surface seen from a shallow angle is mostly a mirror, so a straight-down frame is the only one that shows the water's own colour instead of the sky's.
FresnelThe rule that a surface gets more mirror-like the more side-on you look at it. Look down into a pond and you see the bottom; look along it and you see the sky. At a grazing angle water reflects almost everything, which is why an eye-height view of a river is nearly blind to whatever colour the water itself has been given.
Single Layer WaterUnreal's cheaper water surface — one sheet with a depth-faked body underneath, rather than a simulated volume. It is what makes a river affordable, and it is why the water's colour is set by two numbers rather than by modelling what is actually in the water.
absorption and scatteringThe two numbers that decide what colour a body of water is. Absorption is light the water swallows; scattering is light it throws back out. Their ratio — the single-scattering albedo — is the actual colour you see, so raising scattering alone does nothing if absorption is raised with it.
tupleA fixed row of numbers written in brackets, where the position of each number is what gives it its meaning. (0.25, 0.35, 0.90) is a colour — red, green, blue, in that order — and a fourth number on the end is usually a strength the first three get multiplied by. The order is the whole convention, so a value in the wrong slot is not an error, just a different colour.
material instanceA copy of a material with some settings changed. Cheap to make and cheap to render, so most surfaces are instances rather than originals.
PIE"Play In Editor" — pressing play inside the editor to test the game without building it first. Fast, and it behaves subtly differently from the real build.
retargetingTaking an animation made for one character and making it work on a differently proportioned one.
reference poseThe neutral stance a character model is built in, before any animation is applied. Seeing it in a finished shot means the animation is not running at all.
skeletonThe hierarchy of bones inside an animated model. Two models sharing a skeleton can share animation.
splineA curved line placed in the level that other things follow — a road, a bus route, a river.
CDOThe template a Blueprint stamps out copies from. Editing the template does not always reach the copies already placed in the level, which is a classic silent failure.

Things we still need to fix tomorrow:

  • The river still reads as though it is about to break its banks. The two banks disagree by

2.2 m and it needs another 50 cm of drop, which strands all 3,174 reeds on dry land.

  • There is no riverbed material. The bed under the water is still the green grass lawn, and

fixing it will shift the water colour, so the signed-off setting has to be re-judged after.

  • Landscape grass still grows on the riverbed and is visible through the surface.
  • Rain splashes play on puddles and not on the river.
  • Aircraft contrails render black instead of white.
  • The grass has no seasonal or weather response at all. Only the bad default was fixed.
  • The cat renders. Its behaviour – startle, walk direction, yaw – has still not been watched

for more than a minute.

  • The wheelie bin in that frame is floating, and nobody has looked at why yet.
  • No video has been cut of any of this.

← All devlog entries

Watch it get built. All of this goes up on YouTube as it happens — broken animations, buildings hovering a foot off the ground, the lot.

Subscribe on YouTube