Builtin Library
README.md
postMetrics
Judge a picture by numbers. A tone curve, a bloom, an antialiasing resolve, a fog or a grade is verified by reading what it did to the pixels a viewer sees, held against a reference, rather than by looking at a screenshot.
local postMetrics = require("@builtin::modules.postMetrics")
Every function takes a picture in whatever form one arrives in: the
TextureCpuHandle a presented frame carries (renderer.presentedFrames()
returns frames whose cpu is one), the frame itself, a capture result (its
base64 data), a handle from renderer.texture.readback, or PNG / ZTEX
bytes. Values are code values on the 0-255 scale the picture is delivered at,
unless a function says it reads light.
Comparing with a reference: FLIP and goldens
postMetrics.flip(test, reference, opts?) is NVIDIA FLIP (LDR): how
different two pictures of one size look to a viewer flipping between them,
per pixel in 0..1. mean is the number the reference evaluator reports for a
pair; around 0.01 is a difference nobody sees, around 0.1 one anybody does.
rect = { x, y, w, h } summarises one region of the error map (the map is
still computed over the whole picture, so a region is judged in the context it
is seen in), and errorMap = true hands back the per-pixel error as a grey
picture. The same comparison is a method of every CPU picture:
cpu:flip(reference, opts).
A golden is a PNG of the picture a setup is meant to produce:
local golden = asset.containing(__FILE__).path .. "/goldens/halo_128.png"
postMetrics.expectGolden(frame, golden) -- raises with the FLIP numbers when it differs
local g = postMetrics.golden(frame, golden) -- { passed, status, mean, p95, p99, max, reason }
postMetrics.golden(frame, golden, { record = true }) -- make it, or remake it after a meant change
A golden passes at a FLIP mean of postMetrics.GOLDEN_MAX_MEAN (0.02) or
under, or maxMean / maxP99 when given: tolerant of the noise one GPU,
driver or platform puts on a picture another drew, and failing at a change a
viewer sees. Keep the picture small (a 128 x 128 capture
holds a halo whole) and taken under deterministic = true.
Pixel metrics
| Function | What it reads |
|---|---|
profile(frame, rect?, { axis?, channel?, linear? }) | One value per column (or per row), each the mean over the rectangle: luma, or one channel, in code values or light. |
banding(frame, rect?) | A ramp's longestRun (most consecutive columns holding one value, after averaging rows so dither averages out), distinctCodes and steps. An undithered 8-bit ramp that climbs fewer code values than it has columns holds each for several: a run of 4 or more is a visible band. |
haloOvershoot(frame, rect, { plateau? }) | Across an edge between two flat regions: how far the profile rises above the brighter plateau (overshoot) and falls below the darker (undershoot), and the larger as a share of the step (ratio). Sharpening rings; a clean edge stays between its plateaus. |
temporalVariance(frames, rect?) | Each pixel's variance across a run of frames of one view, summarised (mean, stddev, p95, max). Flicker, shimmer and crawl are variance; a converged resolve of a still view holds every pixel. |
ringEnergy(frame, cx, cy, inner, outer) | The light (decoded from sRGB, Rec. 709 luminance) in a ring around a point: a bloom's spread energy is the ring with the bloom less the ring without it. |
meanColour(frame, rect?) | A region's mean code values, its mean light, and that light's CIELAB L, chroma and hue. |
hueDrift(colours) | How far one colour's CIELAB hue wanders across a series of readings (a saturated patch over exposure): drift from the first, span across all. |
upturns(profile, tol) / halfWidth(profile) | A halo's profile: how often it turns back up (a kernel on a grid does, a pyramid does not), and where it falls to half its source. |
The synthetic HDR chart
@builtin::shaders.post.hdrChart draws a picture of known light in place of
the scene, first in the post chain, so the display path and every effect after
the chart are measured against input that no lighting stood between.
postMetrics.chart.show(props) draws it and chart.hide() takes it down;
props.pattern is "ramp" (low to high across), "greyWedge" (middle
grey at each whole stop from evMin to evMax), "colorChecker" (the 24
ColorChecker Classic patches) or "colorWedge" (the grey wedge's columns, a
saturated color above its grey of the same luminance). dither adds that
many code values of triangular noise in display encoding.
chart.light(props, u, v) is the CPU twin of the shader, and
chart.reference(props, width, height, curve, exposure?) builds the picture
the display should present for the chart under a curve, which flip holds the
presented chart against. chart.patch(props, width, height, stop, half?) is
the rectangle one column covers.
postMetrics.chart.show({ pattern = "colorWedge", evMin = -4, evMax = 6 })
postprocess.setProperty("__display__", "curve", 2)
-- ... capture the frame at 176 x 64 ...
local ref = postMetrics.chart.reference({ pattern = "colorWedge", evMin = -4, evMax = 6 }, 176, 64, 2)
print(postMetrics.flip(frame, ref).mean) -- the display path against its own curve on the CPU
Display curves on the CPU
postMetrics.curves holds what __display__ draws: clamp, reinhard,
aces (Narkowicz 2015, per channel), pbrNeutral(r, g, b) (Khronos PBR
Neutral), acesHuePreserving(r, g, b) (ACES on the largest channel, the
channels keeping their ratios), srgbEncode / srgbDecode, and
display(r, g, b, curve, exposure?), the whole transform to code values,
where curve is the display's number (0 clamp, 1 Reinhard, 2 ACES, 3 PBR
Neutral) or a function.
Scopes
Each scope builds a picture on the CPU and hands back a TextureCpuHandle;
postMetrics.picture(cpu) turns one into an image value that reaches an agent
as a picture when returned from execute.
| Scope | Picture |
|---|---|
scopes.histogram(frame, { rect? }) | Luma and the three channels over the 256 code values; rect is the metering window. |
scopes.waveform(frame) | Each column's pixels plotted by code value, bottom 0 to top 255. |
scopes.vectorscope(frame) | Every pixel's chroma on a disc, with the six primaries and secondaries marked. |
scopes.falseColor(frame, { middleGrey? }) | Each pixel painted by the band of stops it stands at from middle grey; clipped red, crushed near-black. |
scopes.zebra(frame, { minRun?, maxRun? }) | Stripes over every run of one code value that ends in a step of one or two: the contours of a quantised gradient. Also answers how many pixels it marked. |
scopes.toneCurve(curves, { evMin?, evMax? }) | Each curve's output against its input in stops around middle grey. |
local frames = renderer.presentedFrames()
return postMetrics.picture(postMetrics.scopes.waveform(frames[1]))
Canvases
postMetrics.canvas(width, height, fill?) is a blank RGBA8 picture to draw
into with postMetrics.plot(c, x, y, r, g, b), and postMetrics.toImage(c)
makes it a TextureCpuHandle the metrics, flip and picture read: a
reference picture built from first principles.
Reference scenes
@builtin::scenes.postReference.* are scenes of known content, each with a
ReferenceCamera: exposureWedge (a grey wedge, a ColorChecker, lit grey
spheres), bloomEnergy (emitters of 1, 4 and 16 pixels and a sub-pixel moving
specular, at 256 x 256), temporalTorture (a Siemens star, sub-pixel wires, a
fence, a fan, a fast object over a checker, a cut camera, under temporal
antialiasing), banding (a 0..0.1 dark ramp, a sky ramp, a dark fog ramp),
dofChart (focus charts at 2, 4, 8 and 16 units under a lens focused at 4)
and hdrChart (the chart as a scene). Load one with
layers.load("@builtin::scenes.postReference.temporalTorture"); each README
says what it holds and where.