Log inGet started
World

Builtin Library

Branchmain
Branchesmainpublish
Commit61a33237e0

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

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

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