Skip to main content
Auric Artisan · Documentation

Perception & Vision Developer Reference

Maintain and extend the standalone WebGL2 vision simulation runtime, UI contract, state model, export system, URL state and Library integration.

Published: May 24, 2026 Updated: May 24, 2026 Category: Reference Author: Chirag Bansal
Back to Documentation Auric Artisan Home

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.

Table of contents

  1. 1. Code map
  2. 2. Page chrome
  3. 3. UI contract
  4. 4. State model
  5. 5. Renderers
  6. 6. Simulation math
  7. 7. Sources and the render loop
  8. 8. Exports, URL state and API
  9. 9. Library binding
  10. 10. Tests

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-tab and target p-pv-lab, p-pv-sim, p-pv-modes, p-pv-data, p-pv-export and p-pv-reference. Activating a tab dispatches a pv:tab CustomEvent on document, 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-illum and pv-preset, so a programmatic write from a preset, an import or a URL updates the enhanced widget's label.
  • Enlarge: double-clicking pv-canvas or pv-canvas-cpu opens pvs-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) requests webgl2 with preserveDrawingBuffer, 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 shows pv-cpu-notice.
  • compileProgram(modeId) rebuilds the fragment shader when the mode changes. buildFS(modeId) returns the full source, which the tests read.
  • uploadTexture(source) sets UNPACK_FLIP_Y_WEBGL before texImage2D. 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 an ImageData, 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, through renderPreview().
  • 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 declared spatial, 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-entry DECODE table 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 is inv33(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 on pv-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 on Math.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 downloads perception-[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, and that it is not a clinical measurement. Columns: Input, Simulated, L, M, S, RG, BY, DeltaE00, InSRGB.
  • exportJSON() writes settingsObject(), which carries the same provenance. importJSON() reads it back.
  • shareURL() serialises simMode, cvdType, severity, contrast, gamma, blur, noise, bloom, adaptLevel, age, scotomaR, cataractLvl, acuity, illum, adaptStrength, compare, source and renderer. 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;
  • glslMat3 no longer transposing;
  • the mid-chain clamp restored;
  • bradfordAdapt losing its caller;
  • severity no longer blending;
  • the illuminant applied after the visual system;
  • none counted 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.