Perception & Vision Developer Reference
Maintain and extend the standalone WebGL2 vision simulation runtime, UI contract, state model, export system, URL state and Library integration.
Overview
This reference maps the Perception Vision page shell, the simulation engine, the shader runtime, the state model, the dataset register, the views layer, the exports and public API, the Library binding and the test suite. It was rewritten for the 2026 rebuild, which replaced two implementations of the colour-vision projection with one, moved that projection into the space its matrices are defined in, and withdrew a mode that returned a black frame.
1. Code map
+| File | Role |
|---|---|
tool/general/perception-and-neuroscience/perception-vision/index.html
|
SEO shell, masthead, provenance strip, tab rail and the six panels: Lab, Simulation, Modes, Data, Export, Reference. |
js/tool/perception-vision.js |
The engine. Colour science, the eight simulations, the WebGL2 and CPU renderers,
state, readouts, exports and the public API. Exposes
window.AAPerceptionEngine and
window.AAPerceptionVision. |
js/tool/pv/pv-sources.js |
The dataset register: nine entries with a status of verbatim, computed,
synthesised or absent. Exposes window.AAPVSources. |
js/tool/perception-vision-views.js |
The Lab rail segments and the Simulation, Modes, Data, Export and Reference views. Reads from the engine and the register; holds no colour science of its own except the two historical models it reconstructs for comparison. |
js/tool/perception-vision-page.js |
Tab switching, data-pv-goto links, dropdown label sync and the
enlarge-a-canvas overlay. |
css/anz-shell.css, css/perception-vision-shell.css |
The shared workbench slice and this route's sheet. The route loads the slice
instead of analyzer.css, which is worth 75.8 KB. |
tests/colorimetry/pv-harness.js,
pv-reference.test.js, pv-sources.test.js |
53 tests that load the shipped engine under a stubbed DOM. |
js/library/tool-bindings.js |
Registers the route as the perception-vision Library tool. |
2. Page chrome
+- Tabs: buttons carry
data-pv-taband targetp-pv-lab,p-pv-sim,p-pv-modes,p-pv-data,p-pv-exportandp-pv-reference. Activating a tab dispatches apv:tabCustomEvent ondocument, which is how the Modes grid knows it may size its canvases. - In-prose links: an element with
data-pv-goto="p-pv-data"opens that tab. Sentences that name another view use it rather than a dead anchor. - Dropdown sync: the value setter is patched for
pv-source,pv-illumandpv-preset, so a programmatic write from a preset, an import or a URL updates the enhanced widget's label. - Enlarge: double-clicking
pv-canvasorpv-canvas-cpuopenspvs-chart-fs-overlay. - There is no dismissible guide strip. The provenance note above the tabs has no close control, deliberately: it says what the pictures rest on.
3. UI contract
+| Area | Important ids |
|---|---|
| Source | pv-source (sample, ramp, snellen, upload, camera),
pv-file, pv-file-btn, pv-file-name,
pv-btn-cam, pv-cam-status,
pv-row-upload, pv-row-camera |
| Simulation | pvModeSeg writes the hidden pv-sim-mode;
pvTypeSeg writes pv-cvd-type;
pv-severity, pv-chk-daltonize |
| Mode-specific rows | pv-row-cvd, pv-row-adapt,
pv-row-age, pv-row-scotoma,
pv-row-cataract, pv-row-acuity |
| Adjustments | pv-adapt-level, pv-age,
pv-scotoma-r, pv-cataract-lvl,
pv-acuity, pv-contrast, pv-gamma,
pv-blur, pv-noise, pv-bloom |
| Comparison and renderer | pvCompareSeg writes pv-compare (split, sim, diff);
pv-row-split, pv-split-pos;
pvRendererSeg writes pv-renderer (gpu, cpu);
pv-chk-fixate |
| Before the eye | pv-illum (D65, D50, A), pv-adapt-strength |
| Output | pv-stage, pv-canvas (WebGL2),
pv-canvas-cpu (2D), pv-overlay (2D, the fixation
cross and the split divider), pvSideA, pvSideB,
pvViewCaption, pvViewFoot,
pv-renderer-dot, pv-cpu-notice |
| Readouts | pvRamp, pv-ramp-drift, pvRampNote,
pv-neutral-drift, pv-merged, pv-oog,
pv-agree, pv-distinct,
pv-max-shift, pv-lum-min,
pv-lum-mean, pv-lum-max |
| Views | pvChain, pvSimTable, pvSimDrift,
pvModeGrid, pvDeadList, pvDataRows,
pvRefNav, pvRefBody, pv-ref-search |
| Actions | pv-preset, pv-reset, pv-download,
pv-share, pv-batch-input,
pv-batch-run, pv-batch-body,
pv-batch-count, pv-export-csv,
pv-copy-csv, pv-export-json,
pv-import-json, pv-import-json-btn,
pvSettingsPre |
Removed in the rebuild: pv-chk-test, pv-chk-snellen (the chart is a
source now), pv-chk-research, pv-split (replaced by
pv-compare), pv-scale (replaced by pv-acuity),
pv-csf-peak, pv-severity-global, pv-split-line,
pv-snellen-canvas, pv-wcag-aa, pv-wcag-aaa,
pv-ev, pv-copy-settings, pv-paste-settings,
pv-batch-csv, pv-batch-results.
4. State model
+readState() writes to the module-scoped state object and returns it.
Defaults are used when controls are missing.
{
source, simMode, cvdType, severity,
adaptLevel, age, scotomaR, cataractLvl, acuity,
contrast, gamma, blur, noise, bloom,
daltonize, fixation, compare, splitPos,
illum, adaptStrength, renderer
}
simOpts() derives the options object every colour path takes — the shader
uniforms, the CPU renderer, the Export table, the grey-ramp readout and the agreement check
all read the same shape, so a control cannot reach one of them and miss another.
Runtime-scoped variables include gl, glCanvas,
cpuCanvas, overlayCanvas, program,
compiledMode, texId, uLocs,
srcCanvas, webcamVideo, webglOK,
lastAgreement and lastFrameStats.
5. Renderers
+There are two renderers and one implementation. The colour transform composes into a single
3×3 in linear sRGB, built on the CPU by cvdLinearMatrix() and handed to the
shader as u_cvdMat; the illuminant composes into a second matrix,
u_adaptMat. Neither renderer contains a projection of its own.
initGL(canvas)requestswebgl2withpreserveDrawingBuffer, builds the quad and compiles the pass-through shader. It returns false when WebGL2 is unavailable, which puts the page on the CPU renderer and showspv-cpu-notice.compileProgram(modeId)rebuilds the fragment shader when the mode changes.buildFS(modeId)returns the full source, which the tests read.uploadTexture(source)setsUNPACK_FLIP_Y_WEBGLbeforetexImage2D. Without it the viewport draws every source upside down, which it did until this rebuild.renderCPU(ctx, imgData, w, h, p)runs the same per-pixel functions over anImageData, with a separable box blur for the spatial modes. It is the fallback and it is also what the Modes grid uses for its thumbnails, throughrenderPreview().checkRendererAgreement(opts)renders a 216-colour strip through the GPU, reads it back and compares it with the CPU answer. It reports the largest per-channel gap in code values, and it declines to run for a mode declaredspatial, because a one-pixel-tall strip of flat colours cannot check a function of position.
Matrix literals. GLSL's mat3(...) constructor fills columns, so a
matrix written out in reading order arrives transposed. Every matrix in the engine is stored
row-major and passed through glslMat3() or glUniformMat3(), which do
the transpose once. A test asserts that no mat3( literal appears in any shader
body.
No mid-chain clamp. srcLin() applies gamma as
sign(c) * pow(abs(c), gamma) and returns unclamped values. Relighting by an
illuminant can push a channel negative — that is a colour outside sRGB, not an error — and
clamping it before the projection changes the colour the projection is given. With the clamp
in place the two renderers disagreed by 13 code values on pure green under D50; without it
they agree exactly.
6. Simulation math
+The colour-vision chain is
sRGB → linear → XYZ → LMS (HPE) → project → XYZ → linear
→ sRGB. The two XYZ steps were absent before the rebuild: the
Hunt-Pointer-Estévez matrix is published for XYZ, and Viénot's projection
matrices are defined in the space it produces.
srgbToLinear(),linearToSrgb()— IEC 61966-2-1. A 256-entryDECODEtable serves every per-pixel path.rgbToXyz(),xyzToRgb()— the sRGB D65 matrices, both published rather than one derived from the other.xyzToLMS(),lmsToXYZ()— HPE and its computed inverse. The inverse isinv33(M_XYZ2LMS), not a second typed matrix.cvdLinearMatrix(type, severity)— the whole chain composed into one 3×3 in linear sRGB, with the severity blend folded in. Every step either side of the projection is linear, so blending the matrices and blending the results are the same operation; a test asserts it.simulateLinear(r, g, b, opts)— the mode dispatcher. It applies the illuminant first, because light reaches the stimulus before the visual system, then the mode, then daltonisation.simulateHex(hex, opts)— one colour with everything worth reporting: the simulated hex, LMS, opponent channels, ΔE00 and whether the encode clamped.greyRampDrift(opts, steps)— channel spread introduced into a grey ramp, in code values. Zero everywhere is the pass for a dichromacy projection.bradfordMatrix(),adaptLinearMatrix()— Lam 1985, identity under D65, and now with callers.xyzToLab(),deltaE00()— CIEDE2000 including the rotation term; checked against Sharma, Wu & Dalal 2005 test pairs.scotopicSim(),mesopicSim(),lensSim(),cataractSim(),photopicSim()— the stand-in models. Every constant in them is this tool's own and the register says so.
MODES holds the nine list entries. simulates is what the hero badge
may count — none is a real entry and is not a simulation — and
spatial marks the three modes that read their neighbours or their position:
cataracts, macular-degeneration and acuity. The
csf-overlay mode is gone; the register records it as absent.
7. Sources and the render loop
+drawTestCard()— eight colour patches, a nine-step grey ramp and three single-channel ramps. The grey ramp gets a quarter of the card because it is the test that needs no reference.drawGreyRamp(),drawSnellen()— the other two built-in sources. The Snellen chart is a source, not an overlay, so the acuity mode has something to be judged on.loadImageFile(file),startCamera(),stopCamera()— the external sources.drawFrame()reads state, sizes the canvases, renders through whichever renderer is active and draws the overlay.drawOverlay()draws the fixation cross and the split divider onpv-overlay, its own 2D canvas. The cross used to ask the WebGL2 canvas for a 2D context, which returns null.scheduleReadouts()runs the agreement check and the readouts on a 90 ms timer after a change. They used to refresh onMath.random() < 0.1.gamutStats(opts, sourceCanvas)samples the loaded source at 64×48 and reports how far outside sRGB the simulation lands, the largest channel shift, and how many distinct colours it merged.
8. Exports, URL state and API
+exportPNG()composites the active renderer's canvas with the overlay and downloadsperception-[mode]-[severity].png.exportCSV()writes the Export table with a comment header naming the chain, the matrix, the illuminant, the renderer, whether the two renderers agreed, andclinical: false. Columns: Input, Simulated, L, M, S, RG, BY, DeltaE00, InSRGB.exportJSON()writessettingsObject(), which carries the same provenance.importJSON()reads it back.shareURL()serialisessimMode,cvdType,severity,contrast,gamma,blur,noise,bloom,adaptLevel,age,scotomaR,cataractLvl,acuity,illum,adaptStrength,compare,sourceandrenderer.loadURL()restores them at boot.
window.AAPerceptionVision = {
getState(), // the control values
getSettings(), // the provenance object written into exports
agreement(), // the last renderer-agreement result, worst colour included
restoreState(saved),
refresh()
};
window.AAPerceptionEngine = {
// transfer and spaces
srgbToLinear, linearToSrgb, rgbToXyz, xyzToRgb, xyzToLMS, lmsToXYZ,
xyzToLab, deltaE00, lmsToOpponent, luminance, wcagRatio,
// matrices
M_RGB2XYZ, M_XYZ2RGB, M_XYZ2LMS, M_LMS2XYZ, CVD_MATS,
BRAD_M, BRAD_MI, ILLUM_XY, mul33, mm33, inv33, glslMat3,
// simulation
MODES, modeById, simulatingModes, cvdLinearMatrix, simulateCVD,
daltonize, simulateLinear, simulateHex, bradfordMatrix,
adaptLinearMatrix, greyRampDrift, ROD_W,
scotopicSim, mesopicSim, lensSim, cataractSim, photopicSim,
// shader text, sources and previews
buildFS, FS_BODIES, drawTestCard, drawGreyRamp, drawSnellen,
renderPreview, paramFor, encodesInGamut, hexToRgb, rgbToHex
};
window.AAPVSources = { all, get, byStatus, reportable, STATUS_LABEL };
9. Library binding
+attachTool({
matchPath: /^\/tool\/general\/perception-and-neuroscience\/perception-vision\/?/,
tool: {
id: "perception-vision",
name: "Perception & Vision",
href: "/tool/general/perception-and-neuroscience/perception-vision/",
category: "Perception"
},
asset_type: "preset",
capture() {
const c = firstToolCanvas();
if (c) {
return captureCanvasAsImage({
canvasId: c.id,
name: "Perception snapshot",
asset_type: "preset"
});
}
return captureFormPreset({ name: "Perception preset" });
},
restore: restoreFormTool(["pv-render", "pv-apply"])
});
The shared runtime registry also includes
perceptionVision: window.AAPerceptionVision?.getState and restores through
window.AAPerceptionVision?.restoreState. Keep these hooks synchronized if the
public API changes.
10. Tests
+npm run test:colorimetry runs 53 tests for this tool, in
tests/colorimetry/pv-reference.test.js and
pv-sources.test.js. They load the shipped
js/tool/perception-vision.js under a stubbed DOM, so every assertion runs the
code the page runs. Reference values are computed from first principles or quoted from the
publication named beside them.
Each group was proved by injecting the original defect back into the engine and confirming the suite fails. Seventeen mutants, seventeen caught:
- the projection back on linear sRGB, with and without the cone matrices;
glslMat3no longer transposing;- the mid-chain clamp restored;
bradfordAdaptlosing its caller;- severity no longer blending;
- the illuminant applied after the visual system;
nonecounted as a simulation;- the CSF overlay returned to the mode list;
- a spatial mode no longer declaring itself spatial;
- CIEDE2000 losing its rotation term;
- the gamut flag no longer noticing a clamp;
- a uniform declared and never read;
- the rod weighting drifting from the register;
- and three register mutations: a stand-in promoted, a stand-in with no
wouldTake, and a stand-in whose source line stops saying the numbers are this tool's own.
By hand, in a browser: switch through every mode and confirm no console errors; check the grey ramp reads zero under colour vision and the readouts say n/a for a spatial mode; switch the renderer and confirm the frame does not change; set the illuminant to A and confirm the two renderers still agree; move blur and glare and confirm the frame moves; toggle the fixation cross; export the PNG, the CSV and the settings; reload a shared URL; and check the page at 390 px, in both themes and in Hindi.
For user workflow details, see the Perception & Vision User Guide.