(function () {
  'use strict';

  /**
   * @module Constants
   * @submodule Constants
   * @for p5
   */

  const _PI = Math.PI;

  /**
   * Version of this p5.js.
   * @property {String} VERSION
   * @final
   */
  const VERSION = '2.3.2';

  // GRAPHICS RENDERER
  /**
   * The default, two-dimensional renderer in p5.js.
   *
   * Use this when calling <a href="#/p5/createCanvas"> (for example,
   * `createCanvas(400, 400, P2D)`) to specify a 2D context.
   *
   * @typedef {'p2d'} P2D
   * @property {P2D} P2D
   * @final
   */
  const P2D = 'p2d';

  /**
   * An expanded color space (P3) variant of the default, two-dimensional renderer.
   *
   * When available, this mode can allow for extended color ranges and more
   * dynamic color representation. Use it similarly to `P2D`:
   * `createCanvas(400, 400, P2DP3)`.
   *
   * @typedef {'p2d-p3'} P2DP3
   * @property {P2DP3} P2DP3
   * @final
   */

  const P2DP3 = 'p2d-p3';

  /**
   * One of the two render modes in p5.js, used for computationally intensive tasks like 3D rendering and shaders.
   *
   * `WEBGL` differs from the default <a href="/reference/p5/P2D">`P2D`</a> renderer in the following ways:
   *
   * - **Coordinate System** - When drawing in `WEBGL` mode, the origin point (0,0,0) is located at the center of the screen, not the top-left corner. See <a href="https://p5js.org/tutorials/coordinates-and-transformations/">the tutorial page about coordinates and transformations</a>.
   * - **3D Shapes** - `WEBGL` mode can be used to draw 3-dimensional shapes like <a href="#/p5/box">box()</a>, <a href="#/p5/sphere">sphere()</a>, <a href="#/p5/cone">cone()</a>, and <a href="https://p5js.org/reference/#3D%20Primitives">more</a>. See <a href="https://p5js.org/tutorials/custom-geometry/">the tutorial page about custom geometry</a> to make more complex objects.
   * - **Shape Detail** - When drawing in `WEBGL` mode, you can specify how smooth curves should be drawn by using a `detail` parameter. See <a href="https://github.com/processing/p5.js/wiki/Getting-started-with-WebGL-in-p5#3d-primitives-shapes">the wiki section about shapes</a> for a more information and an example.
   * - **Textures** - A texture is like a skin that wraps onto a shape. See <a href="https://github.com/processing/p5.js/wiki/Getting-started-with-WebGL-in-p5#textures">the wiki section about textures</a> for examples of mapping images onto surfaces with textures.
   * - **Materials and Lighting** - `WEBGL` offers different types of lights like <a href="#/p5/ambientLight">ambientLight()</a> to place around a scene. Materials like <a href="#/p5/specularMaterial">specularMaterial()</a> reflect the lighting to convey shape and depth. See <a href="https://p5js.org/tutorials/lights-camera-materials/">the tutorial page for styling and appearance</a> to experiment with different combinations.
   * - **Camera** - The viewport of a `WEBGL` sketch can be adjusted by changing camera attributes. See <a href="https://p5js.org/tutorials/lights-camera-materials#camera-and-view">the tutorial page section about cameras</a> for an explanation of camera controls.
   * - **Text** - `WEBGL` requires opentype/truetype font files to be preloaded using <a href="#/p5/loadFont">loadFont()</a>. See <a href="https://github.com/processing/p5.js/wiki/Getting-started-with-WebGL-in-p5#text">the wiki section about text</a> for details, along with a workaround.
   * - **Shaders** - Shaders are hardware accelerated programs that can be used for a variety of effects and graphics. See the <a href="https://p5js.org/tutorials/intro-to-shaders/">introduction to shaders</a> to get started with shaders in p5.js.
   * - **Graphics Acceleration** - `WEBGL` mode uses the graphics card instead of the CPU, so it may help boost the performance of your sketch (example: drawing more shapes on the screen at once).
   *
   * To learn more about WEBGL mode, check out <a href="https://p5js.org/tutorials/#webgl">all the interactive WEBGL tutorials</a> in the "Tutorials" section of this website, or read the wiki article <a href="https://github.com/processing/p5.js/wiki/Getting-started-with-WebGL-in-p5">"Getting started with WebGL in p5"</a>.
   *
   * @typedef {'webgl'} WEBGL
   * @property {WEBGL} WEBGL
   * @final
   */
  const WEBGL = 'webgl';
  /**
   * One of the two possible values of a WebGL canvas (either WEBGL or WEBGL2),
   * which can be used to determine what capabilities the rendering environment
   * has.
   * @typedef {'webgl2'} WEBGL2
   * @property {WEBGL2} WEBGL2
   * @final
   */
  const WEBGL2 = 'webgl2';

  /**
   * A constant used for creating a WebGPU rendering context
   * @property {'webgpu'} WEBGPU
   * @final
   */
  const WEBGPU = 'webgpu';

  // ENVIRONMENT
  /**
   * @typedef {'default'} ARROW
   * @property {ARROW} ARROW
   * @final
   */
  const ARROW = 'default';

  /**
   * @property {String} SIMPLE
   * @final
   */
  const SIMPLE = 'simple';
  /**
   * @property {String} FULL
   * @final
   */
  const FULL = 'full';

  /**
   * @typedef {'crosshair'} CROSS
   * @property {CROSS} CROSS
   * @final
   */
  const CROSS = 'crosshair';
  /**
   * @typedef {'pointer'} HAND
   * @property {HAND} HAND
   * @final
   */
  const HAND = 'pointer';
  /**
   * @typedef {'move'} MOVE
   * @property {MOVE} MOVE
   * @final
   */
  const MOVE = 'move';
  /**
   * @typedef {'text'} TEXT
   * @property {TEXT} TEXT
   * @final
   */
  const TEXT = 'text';
  /**
   * @typedef {'wait'} WAIT
   * @property {WAIT} WAIT
   * @final
   */
  const WAIT = 'wait';

  // TRIGONOMETRY

  /**
   * A `Number` constant that's approximately 1.5708.
   *
   * `HALF_PI` is half the value of the mathematical constant π. It's useful for
   * many tasks that involve rotation and oscillation. For example, calling
   * `rotate(HALF_PI)` rotates the coordinate system `HALF_PI` radians, which is
   * a quarter turn (90˚).
   *
   * Note: `TWO_PI` radians equals 360˚, `PI` radians equals 180˚, `HALF_PI`
   * radians equals 90˚, and `QUARTER_PI` radians equals 45˚.
   *
   * @property {Number} HALF_PI
   * @final
   *
   * @example
   * function setup() {
   *   createCanvas(100, 100);
   *
   *   background(200);
   *
   *   // Draw an arc from 0 to HALF_PI.
   *   arc(50, 50, 80, 80, 0, HALF_PI);
   *
   *   describe('The bottom-right quarter of a circle drawn in white on a gray background.');
   * }
   *
   * @example
   * function setup() {
   *   createCanvas(100, 100);
   *
   *   background(200);
   *
   *   // Translate the origin to the center.
   *   translate(50, 50);
   *
   *   // Draw a line.
   *   line(0, 0, 40, 0);
   *
   *   // Rotate a quarter turn.
   *   rotate(HALF_PI);
   *
   *   // Draw the same line, rotated.
   *   line(0, 0, 40, 0);
   *
   *   describe('Two black lines on a gray background. One line extends from the center to the right. The other line extends from the center to the bottom.');
   * }
   *
   * @example
   * function setup() {
   *   createCanvas(100, 100);
   *
   *   describe(
   *     'A red circle and a blue circle oscillate from left to right on a gray background. The red circle appears to chase the blue circle.'
   *   );
   * }
   *
   * function draw() {
   *   background(200);
   *
   *   // Translate the origin to the center.
   *   translate(50, 50);
   *
   *   // Calculate the x-coordinates.
   *   let x1 = 40 * sin(frameCount * 0.05);
   *   let x2 = 40 * sin(frameCount * 0.05 + HALF_PI);
   *
   *   // Style the oscillators.
   *   noStroke();
   *
   *   // Draw the red oscillator.
   *   fill(255, 0, 0);
   *   circle(x1, 0, 20);
   *
   *   // Draw the blue oscillator.
   *   fill(0, 0, 255);
   *   circle(x2, 0, 20);
   * }
   */
  const HALF_PI = _PI / 2;

  /**
   * A `Number` constant that's approximately 3.1416.
   *
   * `PI` is the mathematical constant π. It's useful for many tasks that
   * involve rotation and oscillation. For example, calling `rotate(PI)` rotates
   * the coordinate system `PI` radians, which is a half turn (180˚).
   *
   * Note: `TWO_PI` radians equals 360˚, `PI` radians equals 180˚, `HALF_PI`
   * radians equals 90˚, and `QUARTER_PI` radians equals 45˚.
   *
   * @property {Number} PI
   * @final
   *
   * @example
   * function setup() {
   *   createCanvas(100, 100);
   *
   *   background(200);
   *
   *   // Draw an arc from 0 to PI.
   *   arc(50, 50, 80, 80, 0, PI);
   *
   *   describe('The bottom half of a circle drawn in white on a gray background.');
   * }
   *
   * @example
   * function setup() {
   *   createCanvas(100, 100);
   *
   *   background(200);
   *
   *   // Translate the origin to the center.
   *   translate(50, 50);
   *
   *   // Draw a line.
   *   line(0, 0, 40, 0);
   *
   *   // Rotate a half turn.
   *   rotate(PI);
   *
   *   // Draw the same line, rotated.
   *   line(0, 0, 40, 0);
   *
   *   describe('A horizontal black line on a gray background.');
   * }
   *
   * @example
   * function setup() {
   *   createCanvas(100, 100);
   *
   *   describe(
   *     'A red circle and a blue circle oscillate from left to right on a gray background. The circles drift apart, then meet in the middle, over and over again.'
   *   );
   * }
   *
   * function draw() {
   *   background(200);
   *
   *   // Translate the origin to the center.
   *   translate(50, 50);
   *
   *   // Calculate the x-coordinates.
   *   let x1 = 40 * sin(frameCount * 0.05);
   *   let x2 = 40 * sin(frameCount * 0.05 + PI);
   *
   *   // Style the oscillators.
   *   noStroke();
   *
   *   // Draw the red oscillator.
   *   fill(255, 0, 0);
   *   circle(x1, 0, 20);
   *
   *   // Draw the blue oscillator.
   *   fill(0, 0, 255);
   *   circle(x2, 0, 20);
   * }
   */
  const PI = _PI;

  /**
   * A `Number` constant that's approximately 0.7854.
   *
   * `QUARTER_PI` is one-fourth the value of the mathematical constant π. It's
   * useful for many tasks that involve rotation and oscillation. For example,
   * calling `rotate(QUARTER_PI)` rotates the coordinate system `QUARTER_PI`
   * radians, which is an eighth of a turn (45˚).
   *
   * Note: `TWO_PI` radians equals 360˚, `PI` radians equals 180˚, `HALF_PI`
   * radians equals 90˚, and `QUARTER_PI` radians equals 45˚.
   *
   * @property {Number} QUARTER_PI
   * @final
   *
   * @example
   * function setup() {
   *   createCanvas(100, 100);
   *
   *   background(200);
   *
   *   // Draw an arc from 0 to QUARTER_PI.
   *   arc(50, 50, 80, 80, 0, QUARTER_PI);
   *
   *   describe('A one-eighth slice of a circle drawn in white on a gray background.');
   * }
   *
   * @example
   * function setup() {
   *   createCanvas(100, 100);
   *
   *   background(200);
   *
   *   // Translate the origin to the center.
   *   translate(50, 50);
   *
   *   // Draw a line.
   *   line(0, 0, 40, 0);
   *
   *   // Rotate an eighth turn.
   *   rotate(QUARTER_PI);
   *
   *   // Draw the same line, rotated.
   *   line(0, 0, 40, 0);
   *
   *   describe('Two black lines that form a "V" opening towards the bottom-right corner of a gray square.');
   * }
   *
   * @example
   * function setup() {
   *   createCanvas(100, 100);
   *
   *   describe(
   *     'A red circle and a blue circle oscillate from left to right on a gray background. The red circle appears to chase the blue circle.'
   *   );
   * }
   *
   * function draw() {
   *   background(200);
   *
   *   // Translate the origin to the center.
   *   translate(50, 50);
   *
   *   // Calculate the x-coordinates.
   *   let x1 = 40 * sin(frameCount * 0.05);
   *   let x2 = 40 * sin(frameCount * 0.05 + QUARTER_PI);
   *
   *   // Style the oscillators.
   *   noStroke();
   *
   *   // Draw the red oscillator.
   *   fill(255, 0, 0);
   *   circle(x1, 0, 20);
   *
   *   // Draw the blue oscillator.
   *   fill(0, 0, 255);
   *   circle(x2, 0, 20);
   * }
   */
  const QUARTER_PI = _PI / 4;

  /**
   * A `Number` constant that's approximately 6.2382.
   *
   * `TAU` is twice the value of the mathematical constant π. It's useful for
   * many tasks that involve rotation and oscillation. For example, calling
   * `rotate(TAU)` rotates the coordinate system `TAU` radians, which is one
   * full turn (360˚). `TAU` and `TWO_PI` are equal.
   *
   * Note: `TAU` radians equals 360˚, `PI` radians equals 180˚, `HALF_PI`
   * radians equals 90˚, and `QUARTER_PI` radians equals 45˚.
   *
   * @property {Number} TAU
   * @final
   *
   * @example
   * function setup() {
   *   createCanvas(100, 100);
   *
   *   background(200);
   *
   *   // Draw an arc from 0 to TAU.
   *   arc(50, 50, 80, 80, 0, TAU);
   *
   *   describe('A white circle drawn on a gray background.');
   * }
   *
   * @example
   * function setup() {
   *   createCanvas(100, 100);
   *
   *   background(200);
   *
   *   // Translate the origin to the center.
   *   translate(50, 50);
   *
   *   // Draw a line.
   *   line(0, 0, 40, 0);
   *
   *   // Rotate a full turn.
   *   rotate(TAU);
   *
   *   // Style the second line.
   *   strokeWeight(5);
   *
   *   // Draw the same line, shorter and rotated.
   *   line(0, 0, 20, 0);
   *
   *   describe(
   *     'Two horizontal black lines on a gray background. A thick line extends from the center toward the right. A thin line extends from the end of the thick line.'
   *   );
   * }
   *
   * @example
   * function setup() {
   *   createCanvas(100, 100);
   *
   *   describe(
   *     'A red circle with a blue center oscillates from left to right on a gray background.'
   *   );
   * }
   *
   * function draw() {
   *   background(200);
   *
   *   // Translate the origin to the center.
   *   translate(50, 50);
   *
   *   // Calculate the x-coordinates.
   *   let x1 = 40 * sin(frameCount * 0.05);
   *   let x2 = 40 * sin(frameCount * 0.05 + TAU);
   *
   *   // Style the oscillators.
   *   noStroke();
   *
   *   // Draw the red oscillator.
   *   fill(255, 0, 0);
   *   circle(x1, 0, 20);
   *
   *   // Draw the blue oscillator, smaller.
   *   fill(0, 0, 255);
   *   circle(x2, 0, 10);
   * }
   */
  const TAU = _PI * 2;

  /**
   * A `Number` constant that's approximately 6.2382.
   *
   * `TWO_PI` is twice the value of the mathematical constant π. It's useful for
   * many tasks that involve rotation and oscillation. For example, calling
   * `rotate(TWO_PI)` rotates the coordinate system `TWO_PI` radians, which is
   * one full turn (360˚). `TWO_PI` and `TAU` are equal.
   *
   * Note: `TWO_PI` radians equals 360˚, `PI` radians equals 180˚, `HALF_PI`
   * radians equals 90˚, and `QUARTER_PI` radians equals 45˚.
   *
   * @property {Number} TWO_PI
   * @final
   *
   * @example
   * function setup() {
   *   createCanvas(100, 100);
   *
   *   background(200);
   *
   *   // Draw an arc from 0 to TWO_PI.
   *   arc(50, 50, 80, 80, 0, TWO_PI);
   *
   *   describe('A white circle drawn on a gray background.');
   * }
   *
   * @example
   * function setup() {
   *   createCanvas(100, 100);
   *
   *   background(200);
   *
   *   // Translate the origin to the center.
   *   translate(50, 50);
   *
   *   // Draw a line.
   *   line(0, 0, 40, 0);
   *
   *   // Rotate a full turn.
   *   rotate(TWO_PI);
   *
   *   // Style the second line.
   *   strokeWeight(5);
   *
   *   // Draw the same line, shorter and rotated.
   *   line(0, 0, 20, 0);
   *
   *   describe(
   *     'Two horizontal black lines on a gray background. A thick line extends from the center toward the right. A thin line extends from the end of the thick line.'
   *   );
   * }
   *
   * @example
   * function setup() {
   *   createCanvas(100, 100);
   *
   *   describe(
   *     'A red circle with a blue center oscillates from left to right on a gray background.'
   *   );
   * }
   *
   * function draw() {
   *   background(200);
   *
   *   // Translate the origin to the center.
   *   translate(50, 50);
   *
   *   // Calculate the x-coordinates.
   *   let x1 = 40 * sin(frameCount * 0.05);
   *   let x2 = 40 * sin(frameCount * 0.05 + TWO_PI);
   *
   *   // Style the oscillators.
   *   noStroke();
   *
   *   // Draw the red oscillator.
   *   fill(255, 0, 0);
   *   circle(x1, 0, 20);
   *
   *   // Draw the blue oscillator, smaller.
   *   fill(0, 0, 255);
   *   circle(x2, 0, 10);
   * }
   */
  const TWO_PI = _PI * 2;

  /**
   * @property {Number} DEG_TO_RAD
   * @final
   */
  const DEG_TO_RAD = _PI / 180.0;

  /**
   * @property {Number} RAD_TO_DEG
   * @final
   */
  const RAD_TO_DEG = 180.0 / _PI;

  // SHAPE
  /**
   * @typedef {'corner'} CORNER
   * @property {CORNER} CORNER
   * @final
   */
  const CORNER = 'corner';
  /**
   * @typedef {'corners'} CORNERS
   * @property {CORNERS} CORNERS
   * @final
   */
  const CORNERS = 'corners';
  /**
   * @typedef {'radius'} RADIUS
   * @property {RADIUS} RADIUS
   * @final
   */
  const RADIUS = 'radius';
  /**
   * @typedef {'right'} RIGHT
   * @property {RIGHT} RIGHT
   * @final
   */
  const RIGHT = 'right';
  /**
   * @typedef {'left'} LEFT
   * @property {LEFT} LEFT
   * @final
   */
  const LEFT = 'left';
  /**
   * @typedef {'center'} CENTER
   * @property {CENTER} CENTER
   * @final
   */
  const CENTER = 'center';
  /**
   * @typedef {'top'} TOP
   * @property {TOP} TOP
   * @final
   */
  const TOP = 'top';
  /**
   * @typedef {'bottom'} BOTTOM
   * @property {BOTTOM} BOTTOM
   * @final
   */
  const BOTTOM = 'bottom';
  /**
   * @typedef {'alphabetic'} BASELINE
   * @property {BASELINE} BASELINE
   * @final
   */
  const BASELINE = 'alphabetic';
  /**
   * @typedef {0x0000} POINTS
   * @property {POINTS} POINTS
   * @final
   */
  const POINTS = 0x0000;
  /**
   * @typedef {0x0001} LINES
   * @property {LINES} LINES
   * @final
   */
  const LINES = 0x0001;
  /**
   * @typedef {0x0003} LINE_STRIP
   * @property {LINE_STRIP} LINE_STRIP
   * @final
   */
  const LINE_STRIP = 0x0003;
  /**
   * @typedef {0x0002} LINE_LOOP
   * @property {LINE_LOOP} LINE_LOOP
   * @final
   */
  const LINE_LOOP = 0x0002;
  /**
   * @typedef {0x0004} TRIANGLES
   * @property {TRIANGLES} TRIANGLES
   * @final
   */
  const TRIANGLES = 0x0004;
  /**
   * @typedef {0x0006} TRIANGLE_FAN
   * @property {TRIANGLE_FAN} TRIANGLE_FAN
   * @final
   */
  const TRIANGLE_FAN = 0x0006;
  /**
   * @typedef {0x0005} TRIANGLE_STRIP
   * @property {TRIANGLE_STRIP} TRIANGLE_STRIP
   * @final
   */
  const TRIANGLE_STRIP = 0x0005;
  /**
   * @typedef {'quads'} QUADS
   * @property {QUADS} QUADS
   * @final
   */
  const QUADS = 'quads';
  /**
   * @typedef {'quad_strip'} QUAD_STRIP
   * @property {QUAD_STRIP} QUAD_STRIP
   * @final
   */
  const QUAD_STRIP = 'quad_strip';
  /**
   * @typedef {'tess'} TESS
   * @property {TESS} TESS
   * @final
   */
  const TESS = 'tess';
  /**
   * @typedef {0x0007} EMPTY_PATH
   * @property {EMPTY_PATH} EMPTY_PATH
   * @final
   */
  const EMPTY_PATH = 0x0007;
  /**
   * @typedef {0x0008} PATH
   * @property {PATH} PATH
   * @final
   */
  const PATH = 0x0008;
  /**
   * @typedef {'close'} CLOSE
   * @property {CLOSE} CLOSE
   * @final
   */
  const CLOSE = 'close';
  /**
   * @typedef {'open'} OPEN
   * @property {OPEN} OPEN
   * @final
   */
  const OPEN = 'open';
  /**
   * @typedef {'chord'} CHORD
   * @property {CHORD} CHORD
   * @final
   */
  const CHORD = 'chord';
  /**
   * @typedef {'pie'} PIE
   * @property {PIE} PIE
   * @final
   */
  const PIE = 'pie';
  /**
   * @typedef {'square'} PROJECT
   * @property {PROJECT} PROJECT
   * @final
   */
  const PROJECT = 'square'; // PEND: careful this is counterintuitive
  /**
   * @typedef {'butt'} SQUARE
   * @property {SQUARE} SQUARE
   * @final
   */
  const SQUARE = 'butt';
  /**
   * @typedef {'round'} ROUND
   * @property {ROUND} ROUND
   * @final
   */
  const ROUND = 'round';
  /**
   * @typedef {'bevel'} BEVEL
   * @property {BEVEL} BEVEL
   * @final
   */
  const BEVEL = 'bevel';
  /**
   * @typedef {'miter'} MITER
   * @property {MITER} MITER
   * @final
   */
  const MITER = 'miter';

  // DOM EXTENSION
  /**
   * AUTO allows us to automatically set the width or height of an element (but not both),
   * based on the current height and width of the element. Only one parameter can
   * be passed to the <a href="/reference/p5.Element/size">size</a> function as AUTO, at a time.
   *
   * @typedef {'auto'} AUTO
   * @property {AUTO} AUTO
   * @final
   */
  const AUTO = 'auto';
  // INPUT
  /**
   * @typedef {'Alt'} ALT
   * @property {ALT} ALT
   * @final
   */
  const ALT = 'Alt';

  /**
   * @typedef {'Backspace'} BACKSPACE
   * @property {BACKSPACE} BACKSPACE
   * @final
   */
  const BACKSPACE = 'Backspace';

  /**
   * @typedef {'Control' | 'Control'} CONTROL
   * @property {CONTROL} CONTROL
   * @final
   */
  const CONTROL = 'Control';

  /**
   * @typedef {'Delete'} DELETE
   * @property {DELETE} DELETE
   * @final
   */
  const DELETE = 'Delete';

  /**
   * @typedef {'ArrowDown'} DOWN_ARROW
   * @property {DOWN_ARROW} DOWN_ARROW
   * @final
   */
  const DOWN_ARROW = 'ArrowDown';

  /**
   * @typedef {'Enter'} ENTER
   * @property {ENTER} ENTER
   * @final
   */
  const ENTER = 'Enter';

  /**
   * @typedef {'Escape'} ESCAPE
   * @property {ESCAPE} ESCAPE
   * @final
   */
  const ESCAPE = 'Escape';

  /**
   * @typedef {'ArrowLeft'} LEFT_ARROW
   * @property {LEFT_ARROW} LEFT_ARROW
   * @final
   */
  const LEFT_ARROW = 'ArrowLeft';

  /**
   * @typedef {'Alt'} OPTION
   * @property {OPTION} OPTION
   * @final
   */
  const OPTION = 'Alt';

  /**
   * @typedef {'Enter'} RETURN
   * @property {RETURN} RETURN
   * @final
   */
  const RETURN = 'Enter';

  /**
   * @typedef {'ArrowRight'} RIGHT_ARROW
   * @property {RIGHT_ARROW} RIGHT_ARROW
   * @final
   */
  const RIGHT_ARROW = 'ArrowRight';

  /**
   * @typedef {'Shift'} SHIFT
   * @property {SHIFT} SHIFT
   * @final
   */
  const SHIFT = 'Shift';

  /**
   * @typedef {'Tab'} TAB
   * @property {TAB} TAB
   * @final
   */
  const TAB = 'Tab';

  /**
   * @typedef {'ArrowUp'} UP_ARROW
   * @property {UP_ARROW} UP_ARROW
   * @final
   */
  const UP_ARROW = 'ArrowUp';

  // RENDERING
  /**
   * @typedef {'source-over'} BLEND
   * @property {BLEND} BLEND
   * @final
   */
  const BLEND = 'source-over';
  /**
   * @typedef {'destination-out'} REMOVE
   * @property {REMOVE} REMOVE
   * @final
   */
  const REMOVE = 'destination-out';
  /**
   * @typedef {'lighter'} ADD
   * @property {ADD} ADD
   * @final
   */
  const ADD = 'lighter';
  /**
   * @typedef {'darken'} DARKEST
   * @property {DARKEST} DARKEST
   * @final
   */
  const DARKEST = 'darken';
  /**
   * @typedef {'lighten'} LIGHTEST
   * @property {LIGHTEST} LIGHTEST
   * @final
   */
  const LIGHTEST = 'lighten';
  /**
   * @typedef {'difference'} DIFFERENCE
   * @property {DIFFERENCE} DIFFERENCE
   * @final
   */
  const DIFFERENCE = 'difference';
  /**
   * @typedef {'subtract'} SUBTRACT
   * @property {SUBTRACT} SUBTRACT
   * @final
   */
  const SUBTRACT = 'subtract';
  /**
   * @typedef {'exclusion'} EXCLUSION
   * @property {EXCLUSION} EXCLUSION
   * @final
   */
  const EXCLUSION = 'exclusion';
  /**
   * @typedef {'multiply'} MULTIPLY
   * @property {MULTIPLY} MULTIPLY
   * @final
   */
  const MULTIPLY = 'multiply';
  /**
   * @typedef {'screen'} SCREEN
   * @property {SCREEN} SCREEN
   * @final
   */
  const SCREEN = 'screen';
  /**
   * @typedef {'copy'} REPLACE
   * @property {REPLACE} REPLACE
   * @final
   */
  const REPLACE = 'copy';
  /**
   * @typedef {'overlay'} OVERLAY
   * @property {OVERLAY} OVERLAY
   * @final
   */
  const OVERLAY = 'overlay';
  /**
   * @typedef {'hard-light'} HARD_LIGHT
   * @property {HARD_LIGHT} HARD_LIGHT
   * @final
   */
  const HARD_LIGHT = 'hard-light';
  /**
   * @typedef {'soft-light'} SOFT_LIGHT
   * @property {SOFT_LIGHT} SOFT_LIGHT
   * @final
   */
  const SOFT_LIGHT = 'soft-light';
  /**
   * @typedef {'color-dodge'} DODGE
   * @property {DODGE} DODGE
   * @final
   */
  const DODGE = 'color-dodge';
  /**
   * @typedef {'color-burn'} BURN
   * @property {BURN} BURN
   * @final
   */
  const BURN = 'color-burn';

  // FILTERS
  /**
   * @typedef {'threshold'} THRESHOLD
   * @property {THRESHOLD} THRESHOLD
   * @final
   */
  const THRESHOLD = 'threshold';
  /**
   * @typedef {'gray'} GRAY
   * @property {GRAY} GRAY
   * @final
   */
  const GRAY = 'gray';
  /**
   * @typedef {'opaque'} OPAQUE
   * @property {OPAQUE} OPAQUE
   * @final
   */
  const OPAQUE = 'opaque';
  /**
   * @typedef {'invert'} INVERT
   * @property {INVERT} INVERT
   * @final
   */
  const INVERT = 'invert';
  /**
   * @typedef {'posterize'} POSTERIZE
   * @property {POSTERIZE} POSTERIZE
   * @final
   */
  const POSTERIZE = 'posterize';
  /**
   * @typedef {'dilate'} DILATE
   * @property {DILATE} DILATE
   * @final
   */
  const DILATE = 'dilate';
  /**
   * @typedef {'erode'} ERODE
   * @property {ERODE} ERODE
   * @final
   */
  const ERODE = 'erode';
  /**
   * @typedef {'blur'} BLUR
   * @property {BLUR} BLUR
   * @final
   */
  const BLUR = 'blur';

  // TYPOGRAPHY
  /**
   * @typedef {'normal'} NORMAL
   * @property {NORMAL} NORMAL
   * @final
   */
  const NORMAL = 'normal';
  /**
   * @typedef {'italic'} ITALIC
   * @property {ITALIC} ITALIC
   * @final
   */
  const ITALIC = 'italic';
  /**
   * @typedef {'bold'} BOLD
   * @property {BOLD} BOLD
   * @final
   */
  const BOLD = 'bold';
  /**
   * @typedef {'bold italic'} BOLDITALIC
   * @property {BOLDITALIC} BOLDITALIC
   * @final
   */
  const BOLDITALIC = 'bold italic';
  /**
   * @typedef {'CHAR'} CHAR
   * @property {CHAR} CHAR
   * @final
   */
  const CHAR = 'CHAR';
  /**
   * @typedef {'WORD'} WORD
   * @property {WORD} WORD
   * @final
   */
  const WORD = 'WORD';

  // TYPOGRAPHY-INTERNAL
  const _DEFAULT_TEXT_FILL = '#000000';
  const _DEFAULT_LEADMULT = 1.25;
  const _CTX_MIDDLE = 'middle';

  // VERTICES
  /**
   * @typedef {'linear'} LINEAR
   * @property {LINEAR} LINEAR
   * @final
   */
  const LINEAR = 'linear';
  /**
   * @typedef {'quadratic'} QUADRATIC
   * @property {QUADRATIC} QUADRATIC
   * @final
   */
  const QUADRATIC = 'quadratic';
  /**
   * @typedef {'bezier'} BEZIER
   * @property {BEZIER} BEZIER
   * @final
   */
  const BEZIER = 'bezier';
  /**
   * @typedef {'curve'} CURVE
   * @property {CURVE} CURVE
   * @final
   */
  const CURVE = 'curve';

  // WEBGL DRAWMODES
  /**
   * @typedef {'stroke'} STROKE
   * @property {STROKE} STROKE
   * @final
   */
  const STROKE = 'stroke';
  /**
   * @typedef {'fill'} FILL
   * @property {FILL} FILL
   * @final
   */
  const FILL = 'fill';
  /**
   * @typedef {'texture'} TEXTURE
   * @property {TEXTURE} TEXTURE
   * @final
   */
  const TEXTURE = 'texture';
  /**
   * @typedef {'immediate'} IMMEDIATE
   * @property {IMMEDIATE} IMMEDIATE
   * @final
   */
  const IMMEDIATE = 'immediate';

  // WEBGL TEXTURE MODE
  // NORMAL already exists for typography
  /**
   * @typedef {'image'} IMAGE
   * @property {IMAGE} IMAGE
   * @final
   */
  const IMAGE = 'image';

  // WEBGL TEXTURE WRAP AND FILTERING
  // LINEAR already exists above
  /**
   * @typedef {'linear_mipmap'} LINEAR_MIPMAP
   * @property {LINEAR_MIPMAP} LINEAR_MIPMAP
   * @final
   * @private
   */
  const LINEAR_MIPMAP = 'linear_mipmap';
  /**
   * @typedef {'nearest'} NEAREST
   * @property {NEAREST} NEAREST
   * @final
   */
  const NEAREST = 'nearest';
  /**
   * @typedef {'repeat'} REPEAT
   * @property {REPEAT} REPEAT
   * @final
   */
  const REPEAT = 'repeat';
  /**
   * @typedef {'clamp'} CLAMP
   * @property {CLAMP} CLAMP
   * @final
   */
  const CLAMP = 'clamp';
  /**
   * @typedef {'mirror'} MIRROR
   * @property {MIRROR} MIRROR
   * @final
   */
  const MIRROR = 'mirror';

  // WEBGL GEOMETRY SHADING
  /**
   * @typedef {'flat'} FLAT
   * @property {FLAT} FLAT
   * @final
   */
  const FLAT = 'flat';
  /**
   * @typedef {'smooth'} SMOOTH
   * @property {SMOOTH} SMOOTH
   * @final
   */
  const SMOOTH = 'smooth';

  // DEVICE-ORIENTATION
  /**
   * @typedef {'landscape'} LANDSCAPE
   * @property {LANDSCAPE} LANDSCAPE
   * @final
   */
  const LANDSCAPE = 'landscape';
  /**
   * @typedef {'portrait'} PORTRAIT
   * @property {PORTRAIT} PORTRAIT
   * @final
   */
  const PORTRAIT = 'portrait';

  // DEFAULTS
  const _DEFAULT_STROKE = '#000000';
  const _DEFAULT_FILL = '#FFFFFF';

  /**
   * @typedef {'grid'} GRID
   * @property {GRID} GRID
   * @final
   */
  const GRID = 'grid';

  /**
   * @typedef {'axes'} AXES
   * @property {AXES} AXES
   * @final
   */
  const AXES = 'axes';

  /**
   * @typedef {'label'} LABEL
   * @property {LABEL} LABEL
   * @final
   */
  const LABEL = 'label';
  /**
   * @typedef {'fallback'} FALLBACK
   * @property {FALLBACK} FALLBACK
   * @final
   */
  const FALLBACK = 'fallback';

  /**
   * @typedef {'contain'} CONTAIN
   * @property {CONTAIN} CONTAIN
   * @final
   */
  const CONTAIN = 'contain';

  /**
   * @typedef {'cover'} COVER
   * @property {COVER} COVER
   * @final
   */
  const COVER = 'cover';

  /**
   * @typedef {'unsigned-byte'} UNSIGNED_BYTE
   * @property {UNSIGNED_BYTE} UNSIGNED_BYTE
   * @final
   */
  const UNSIGNED_BYTE = 'unsigned-byte';

  /**
   * @typedef {'unsigned-int'} UNSIGNED_INT
   * @property {UNSIGNED_INT} UNSIGNED_INT
   * @final
   */
  const UNSIGNED_INT = 'unsigned-int';

  /**
   * @typedef {'float'} FLOAT
   * @property {FLOAT} FLOAT
   * @final
   */
  const FLOAT = 'float';

  /**
   * @typedef {'half-float'} HALF_FLOAT
   * @property {HALF_FLOAT} HALF_FLOAT
   * @final
   */
  const HALF_FLOAT = 'half-float';

  /**
   * The `splineProperty('ends')` mode where splines curve through
   * their first and last points.
   * @typedef {unique symbol} INCLUDE
   * @property {INCLUDE} INCLUDE
   * @final
   */
  const INCLUDE = Symbol('include');

  /**
   * The `splineProperty('ends')` mode where the first and last points in a spline
   * affect the direction of the curve, but are not rendered.
   * @typedef {unique symbol} EXCLUDE
   * @property {EXCLUDE} EXCLUDE
   * @final
   */
  const EXCLUDE = Symbol('exclude');

  /**
   * The `splineProperty('ends')` mode where the spline loops back to its first point.
   * Only used internally.
   * @typedef {unique symbol} JOIN
   * @property {JOIN} JOIN
   * @final
   * @private
   */
  const JOIN = Symbol('join');

  var constants = /*#__PURE__*/Object.freeze({
    __proto__: null,
    ADD: ADD,
    ALT: ALT,
    ARROW: ARROW,
    AUTO: AUTO,
    AXES: AXES,
    BACKSPACE: BACKSPACE,
    BASELINE: BASELINE,
    BEVEL: BEVEL,
    BEZIER: BEZIER,
    BLEND: BLEND,
    BLUR: BLUR,
    BOLD: BOLD,
    BOLDITALIC: BOLDITALIC,
    BOTTOM: BOTTOM,
    BURN: BURN,
    CENTER: CENTER,
    CHAR: CHAR,
    CHORD: CHORD,
    CLAMP: CLAMP,
    CLOSE: CLOSE,
    CONTAIN: CONTAIN,
    CONTROL: CONTROL,
    CORNER: CORNER,
    CORNERS: CORNERS,
    COVER: COVER,
    CROSS: CROSS,
    CURVE: CURVE,
    DARKEST: DARKEST,
    DEG_TO_RAD: DEG_TO_RAD,
    DELETE: DELETE,
    DIFFERENCE: DIFFERENCE,
    DILATE: DILATE,
    DODGE: DODGE,
    DOWN_ARROW: DOWN_ARROW,
    EMPTY_PATH: EMPTY_PATH,
    ENTER: ENTER,
    ERODE: ERODE,
    ESCAPE: ESCAPE,
    EXCLUDE: EXCLUDE,
    EXCLUSION: EXCLUSION,
    FALLBACK: FALLBACK,
    FILL: FILL,
    FLAT: FLAT,
    FLOAT: FLOAT,
    FULL: FULL,
    GRAY: GRAY,
    GRID: GRID,
    HALF_FLOAT: HALF_FLOAT,
    HALF_PI: HALF_PI,
    HAND: HAND,
    HARD_LIGHT: HARD_LIGHT,
    IMAGE: IMAGE,
    IMMEDIATE: IMMEDIATE,
    INCLUDE: INCLUDE,
    INVERT: INVERT,
    ITALIC: ITALIC,
    JOIN: JOIN,
    LABEL: LABEL,
    LANDSCAPE: LANDSCAPE,
    LEFT: LEFT,
    LEFT_ARROW: LEFT_ARROW,
    LIGHTEST: LIGHTEST,
    LINEAR: LINEAR,
    LINEAR_MIPMAP: LINEAR_MIPMAP,
    LINES: LINES,
    LINE_LOOP: LINE_LOOP,
    LINE_STRIP: LINE_STRIP,
    MIRROR: MIRROR,
    MITER: MITER,
    MOVE: MOVE,
    MULTIPLY: MULTIPLY,
    NEAREST: NEAREST,
    NORMAL: NORMAL,
    OPAQUE: OPAQUE,
    OPEN: OPEN,
    OPTION: OPTION,
    OVERLAY: OVERLAY,
    P2D: P2D,
    P2DP3: P2DP3,
    PATH: PATH,
    PI: PI,
    PIE: PIE,
    POINTS: POINTS,
    PORTRAIT: PORTRAIT,
    POSTERIZE: POSTERIZE,
    PROJECT: PROJECT,
    QUADRATIC: QUADRATIC,
    QUADS: QUADS,
    QUAD_STRIP: QUAD_STRIP,
    QUARTER_PI: QUARTER_PI,
    RADIUS: RADIUS,
    RAD_TO_DEG: RAD_TO_DEG,
    REMOVE: REMOVE,
    REPEAT: REPEAT,
    REPLACE: REPLACE,
    RETURN: RETURN,
    RIGHT: RIGHT,
    RIGHT_ARROW: RIGHT_ARROW,
    ROUND: ROUND,
    SCREEN: SCREEN,
    SHIFT: SHIFT,
    SIMPLE: SIMPLE,
    SMOOTH: SMOOTH,
    SOFT_LIGHT: SOFT_LIGHT,
    SQUARE: SQUARE,
    STROKE: STROKE,
    SUBTRACT: SUBTRACT,
    TAB: TAB,
    TAU: TAU,
    TESS: TESS,
    TEXT: TEXT,
    TEXTURE: TEXTURE,
    THRESHOLD: THRESHOLD,
    TOP: TOP,
    TRIANGLES: TRIANGLES,
    TRIANGLE_FAN: TRIANGLE_FAN,
    TRIANGLE_STRIP: TRIANGLE_STRIP,
    TWO_PI: TWO_PI,
    UNSIGNED_BYTE: UNSIGNED_BYTE,
    UNSIGNED_INT: UNSIGNED_INT,
    UP_ARROW: UP_ARROW,
    VERSION: VERSION,
    WAIT: WAIT,
    WEBGL: WEBGL,
    WEBGL2: WEBGL2,
    WEBGPU: WEBGPU,
    WORD: WORD,
    _CTX_MIDDLE: _CTX_MIDDLE,
    _DEFAULT_FILL: _DEFAULT_FILL,
    _DEFAULT_LEADMULT: _DEFAULT_LEADMULT,
    _DEFAULT_STROKE: _DEFAULT_STROKE,
    _DEFAULT_TEXT_FILL: _DEFAULT_TEXT_FILL
  });

  function getStrokeDefs(shaderConstant) {
    const STROKE_CAP_ENUM = {};
    const STROKE_JOIN_ENUM = {};
    let lineDefs = "";
    const defineStrokeCapEnum = function (key, val) {
      lineDefs += shaderConstant(`STROKE_CAP_${key}`, `${val}`, 'u32');
      STROKE_CAP_ENUM[constants[key]] = val;
    };
    const defineStrokeJoinEnum = function (key, val) {
      lineDefs += shaderConstant(`STROKE_JOIN_${key}`, `${val}`, 'u32');
      STROKE_JOIN_ENUM[constants[key]] = val;
    };

    // Define constants in line shaders for each type of cap/join, and also record
    // the values in JS objects
    defineStrokeCapEnum("ROUND", 0);
    defineStrokeCapEnum("PROJECT", 1);
    defineStrokeCapEnum("SQUARE", 2);
    defineStrokeJoinEnum("ROUND", 0);
    defineStrokeJoinEnum("MITER", 1);
    defineStrokeJoinEnum("BEVEL", 2);

    return { STROKE_CAP_ENUM, STROKE_JOIN_ENUM, lineDefs };
  }

  /////////////////////
  // Enums for nodes //
  /////////////////////
  const NodeType = {
    OPERATION: 'operation',
    LITERAL: 'literal',
    VARIABLE: 'variable',
    CONSTANT: 'constant',
    STRUCT: 'struct',
    PHI: 'phi',
    STATEMENT: 'statement',
    ASSIGNMENT: 'assignment',
  };
  const INSTANCE_ID_VARYING_NAME = '_p5_instanceID';
  const HOOK_PARAM_PREFIX = '_p5_param_';
  const NodeTypeToName = Object.fromEntries(
    Object.entries(NodeType).map(([key, val]) => [val, key])
  );
  const NodeTypeRequiredFields = {
    [NodeType.OPERATION]: ["opCode", "dependsOn", "dimension", "baseType"],
    [NodeType.LITERAL]: ["value", "dimension", "baseType"],
    [NodeType.VARIABLE]: ["identifier", "dimension", "baseType"],
    [NodeType.CONSTANT]: ["value", "dimension", "baseType"],
    [NodeType.STRUCT]: [""],
    [NodeType.PHI]: ["dependsOn", "phiBlocks", "dimension", "baseType"],
    [NodeType.STATEMENT]: ["statementType"],
    [NodeType.ASSIGNMENT]: ["dependsOn"]
  };
  const StatementType = {
    DISCARD: 'discard',
    BREAK: 'break',
    EARLY_RETURN: 'early_return',
    EXPRESSION: 'expression', // Used when we want to output a single expression as a statement, e.g. a for loop condition
    EMPTY: 'empty', // Used for empty statements like ; in for loops
  };
  const BaseType = {
    FLOAT: "float",
    INT: "int",
    BOOL: "bool",
    MAT: "mat",
    DEFER: "defer",
    ASSIGN_ON_USE: "assign_on_use",
    SAMPLER2D: "sampler2D",
    SAMPLER: "sampler",
  };
  const BasePriority = {
    [BaseType.FLOAT]: 3,
    [BaseType.INT]: 2,
    [BaseType.BOOL]: 1,
    [BaseType.MAT]: 0,
    [BaseType.DEFER]: -1,
    [BaseType.ASSIGN_ON_USE]: -2,
    [BaseType.SAMPLER2D]: -10,
    [BaseType.SAMPLER]: -11,
  };
  const DataType = {
    float1: { fnName: "float", baseType: BaseType.FLOAT, dimension:1, priority: 3,  },
    float2: { fnName: "vec2", baseType: BaseType.FLOAT, dimension:2, priority: 3,  },
    float3: { fnName: "vec3", baseType: BaseType.FLOAT, dimension:3, priority: 3,  },
    float4: { fnName: "vec4", baseType: BaseType.FLOAT, dimension:4, priority: 3,  },
    int1: { fnName: "int", baseType: BaseType.INT, dimension:1, priority: 2,  },
    int2: { fnName: "ivec2", baseType: BaseType.INT, dimension:2, priority: 2,  },
    int3: { fnName: "ivec3", baseType: BaseType.INT, dimension:3, priority: 2,  },
    int4: { fnName: "ivec4", baseType: BaseType.INT, dimension:4, priority: 2,  },
    bool1: { fnName: "bool", baseType: BaseType.BOOL, dimension:1, priority: 1,  },
    bool2: { fnName: "bvec2", baseType: BaseType.BOOL, dimension:2, priority: 1,  },
    bool3: { fnName: "bvec3", baseType: BaseType.BOOL, dimension:3, priority: 1,  },
    bool4: { fnName: "bvec4", baseType: BaseType.BOOL, dimension:4, priority: 1,  },
    mat2: { fnName: "mat2x2", baseType: BaseType.MAT, dimension:2, priority: 0,  },
    mat3: { fnName: "mat3x3", baseType: BaseType.MAT, dimension:3, priority: 0,  },
    mat4: { fnName: "mat4x4", baseType: BaseType.MAT, dimension:4, priority: 0,  },
    defer: { fnName:  null, baseType: BaseType.DEFER, dimension: null, priority: -1 },
    assign_on_use: { fnName: null, baseType: BaseType.ASSIGN_ON_USE, dimension: null, priority: -2 },
    sampler2D: { fnName: "sampler2D", baseType: BaseType.SAMPLER2D, dimension: 1, priority: -10 },
    sampler: { fnName: "sampler", baseType: BaseType.SAMPLER, dimension: 1, priority: -11 },
  };
  function isStructType(typeInfo) {
    return !!(typeInfo && typeInfo.properties);
  }
  const GenType = {
    FLOAT: { baseType: BaseType.FLOAT, dimension: null, priority: 3 },
    INT: { baseType: BaseType.INT, dimension: null, priority: 2 },
    BOOL: { baseType: BaseType.BOOL, dimension: null, priority: 1 },
  };
  function typeEquals(nodeA, nodeB) {
    return (nodeA.dimension === nodeB.dimension) && (nodeA.baseType === nodeB.baseType);
  }
  Object.fromEntries(
    Object.values(DataType)
      .filter(info => info.fnName !== null)
      .map(info => [info.fnName, info])
  );
  const OpCode = {
    Binary: {
      ADD: 0,
      SUBTRACT: 1,
      MULTIPLY: 2,
      DIVIDE: 3,
      MODULO: 4,
      EQUAL: 5,
      NOT_EQUAL: 6,
      GREATER_THAN: 7,
      GREATER_EQUAL: 8,
      LESS_THAN: 9,
      LESS_EQUAL: 10,
      LOGICAL_AND: 11,
      LOGICAL_OR: 12,
      MEMBER_ACCESS: 13,
      ARRAY_ACCESS: 14,
    },
    Unary: {
      LOGICAL_NOT: 100,
      NEGATE: 101,
      PLUS: 102,
      SWIZZLE: 103,
    },
    Nary: {
      FUNCTION_CALL: 200,
      CONSTRUCTOR: 201,
      TERNARY: 202}};
  const OperatorTable = [
    { arity: "unary", boolean: true, name: "not", symbol: "!", opCode: OpCode.Unary.LOGICAL_NOT },
    { arity: "unary", name: "neg", symbol: "-", opCode: OpCode.Unary.NEGATE },
    { arity: "unary", name: "plus", symbol: "+", opCode: OpCode.Unary.PLUS },
    { arity: "binary", name: "add", symbol: "+", opCode: OpCode.Binary.ADD },
    { arity: "binary", name: "sub", symbol: "-", opCode: OpCode.Binary.SUBTRACT },
    { arity: "binary", name: "mult", symbol: "*", opCode: OpCode.Binary.MULTIPLY },
    { arity: "binary", name: "div", symbol: "/", opCode: OpCode.Binary.DIVIDE },
    { arity: "binary", name: "mod", symbol: "%", opCode: OpCode.Binary.MODULO },
    { arity: "binary", boolean: true, name: "equalTo", symbol: "==", opCode: OpCode.Binary.EQUAL },
    { arity: "binary", boolean: true, name: "notEqual", symbol: "!=", opCode: OpCode.Binary.NOT_EQUAL },
    { arity: "binary", boolean: true, name: "greaterThan", symbol: ">", opCode: OpCode.Binary.GREATER_THAN },
    { arity: "binary", boolean: true, name: "greaterEqual", symbol: ">=", opCode: OpCode.Binary.GREATER_EQUAL },
    { arity: "binary", boolean: true, name: "lessThan", symbol: "<", opCode: OpCode.Binary.LESS_THAN },
    { arity: "binary", boolean: true, name: "lessEqual", symbol: "<=", opCode: OpCode.Binary.LESS_EQUAL },
    { arity: "binary", boolean: true, name: "and", symbol: "&&", opCode: OpCode.Binary.LOGICAL_AND },
    { arity: "binary", boolean: true, name: "or", symbol: "||", opCode: OpCode.Binary.LOGICAL_OR },
  ];
  // export const SymbolToOpCode = {};
  const OpCodeToSymbol = {};
  for (const { symbol, opCode, name, arity, boolean } of OperatorTable) {
    // SymbolToOpCode[symbol] = opCode;
    OpCodeToSymbol[opCode] = symbol;
  }
  const BlockType = {
    GLOBAL: 'global',
    FUNCTION: 'function',
    BRANCH: 'branch',
    IF_COND: 'if_cond',
    IF_BODY: 'if_body',
    ELSE_COND: 'else_cond',
    SCOPE_START: 'scope_start',
    SCOPE_END: 'scope_end',
    FOR: 'for',
    MERGE: 'merge',
    DEFAULT: 'default',
  };
  Object.fromEntries(
    Object.entries(BlockType).map(([key, val]) => [val, key])
  );

  const uniforms$5 = `
// Group 0: Material Properties
struct MaterialUniforms {
  uUseVertexColor: u32,
}

// Group 1: Model Transform
struct ModelUniforms {
// @p5 ifdef Vertex getWorldInputs
  uModelMatrix: mat4x4<f32>,
  uModelNormalMatrix: mat3x3<f32>,
// @p5 endif
// @p5 ifndef Vertex getWorldInputs
  uModelViewMatrix: mat4x4<f32>,
  uNormalMatrix: mat3x3<f32>,
// @p5 endif
  uMaterialColor: vec4<f32>,
}

// Group 2: Camera and Projection
struct CameraUniforms {
  uProjectionMatrix: mat4x4<f32>,
// @p5 ifdef Vertex getWorldInputs
  uViewMatrix: mat4x4<f32>,
// @p5 endif
  uCameraNormalMatrix: mat3x3<f32>,
}
`;

  const colorVertexShader = `
struct VertexInput {
  @location(0) aPosition: vec3<f32>,
  @location(1) aNormal: vec3<f32>,
  @location(2) aTexCoord: vec2<f32>,
  @location(3) aVertexColor: vec4<f32>,
};

struct VertexOutput {
  @builtin(position) Position: vec4<f32>,
  @location(0) vVertexNormal: vec3<f32>,
  @location(1) vVertTexCoord: vec2<f32>,
  @location(2) vColor: vec4<f32>,
};

${uniforms$5}
@group(0) @binding(0) var<uniform> material: MaterialUniforms;
@group(1) @binding(0) var<uniform> model: ModelUniforms;
@group(2) @binding(0) var<uniform> camera: CameraUniforms;

struct Vertex {
  position: vec3<f32>,
  normal: vec3<f32>,
  texCoord: vec2<f32>,
  color: vec4<f32>,
}

@vertex
fn main(input: VertexInput) -> VertexOutput {
  HOOK_beforeVertex();
  var output: VertexOutput;

  let useVertexColor = (material.uUseVertexColor != 0 && input.aVertexColor.x >= 0.0);
  var inputs = Vertex(
    input.aPosition,
    input.aNormal,
    input.aTexCoord,
    select(model.uMaterialColor, input.aVertexColor, useVertexColor)
  );

// @p5 ifdef Vertex getObjectInputs
  inputs = HOOK_getObjectInputs(inputs);
// @p5 endif

// @p5 ifdef Vertex getWorldInputs
  inputs.position = (model.uModelMatrix * vec4<f32>(inputs.position, 1.0)).xyz;
  inputs.normal = model.uModelNormalMatrix * inputs.normal;
  inputs = HOOK_getWorldInputs(inputs);
// @p5 endif

// @p5 ifdef Vertex getWorldInputs
  // Already multiplied by the model matrix, just apply view
  inputs.position = (camera.uViewMatrix * vec4<f32>(inputs.position, 1.0)).xyz;
  inputs.normal = camera.uCameraNormalMatrix * inputs.normal;
// @p5 endif
// @p5 ifndef Vertex getWorldInputs
  // Apply both at once
  inputs.position = (model.uModelViewMatrix * vec4<f32>(inputs.position, 1.0)).xyz;
  inputs.normal = model.uNormalMatrix * inputs.normal;
// @p5 endif

// @p5 ifdef Vertex getCameraInputs
  inputs = HOOK_getCameraInputs(inputs);
// @p5 endif

  output.vVertTexCoord = inputs.texCoord;
  output.vVertexNormal = normalize(inputs.normal);
  output.vColor = inputs.color;

  output.Position = camera.uProjectionMatrix * vec4<f32>(inputs.position, 1.0);

  HOOK_afterVertex();
  return output;
}
`;

  const colorFragmentShader = `
struct FragmentInput {
  @location(0) vVertexNormal: vec3<f32>,
  @location(1) vVertTexCoord: vec2<f32>,
  @location(2) vColor: vec4<f32>,
};

${uniforms$5}
@group(0) @binding(0) var<uniform> material: MaterialUniforms;
@group(1) @binding(0) var<uniform> model: ModelUniforms;
@group(2) @binding(0) var<uniform> camera: CameraUniforms;


@fragment
fn main(input: FragmentInput) -> @location(0) vec4<f32> {
  HOOK_beforeFragment();
  var outColor = HOOK_getFinalColor(input.vColor, input.vVertTexCoord);
  outColor = vec4<f32>(outColor.rgb * outColor.a, outColor.a);
  HOOK_afterFragment();
  return outColor;
}
`;

  const uniforms$4 = `
// Group 0: Stroke Properties
struct StrokeUniforms {
  uStrokeWeight: f32,
  uUseLineColor: f32,
  uSimpleLines: f32,
  uStrokeCap: u32,
  uStrokeJoin: u32,
}

// Group 1: Model Transform
struct ModelUniforms {
// @p5 ifdef StrokeVertex getWorldInputs
  uModelMatrix: mat4x4<f32>,
  uViewMatrix: mat4x4<f32>,
// @p5 endif
// @p5 ifndef StrokeVertex getWorldInputs
  uModelViewMatrix: mat4x4<f32>,
// @p5 endif
  uMaterialColor: vec4<f32>,
}

// Group 2: Camera and Projection
struct CameraUniforms {
  uProjectionMatrix: mat4x4<f32>,
  uViewport: vec4<f32>,
  uPerspective: u32,
}
`;

  const lineVertexShader = `
struct StrokeVertexInput {
  @location(0) aPosition: vec3<f32>,
  @location(1) aSide: f32,
  @location(2) aTangentIn: vec3<f32>,
  @location(3) aTangentOut: vec3<f32>,
  @location(4) aVertexColor: vec4<f32>,
};

struct StrokeVertexOutput {
  @builtin(position) Position: vec4<f32>,
  @location(0) vColor: vec4<f32>,
  @location(1) vTangent: vec2<f32>,
  @location(2) vCenter: vec2<f32>,
  @location(3) vPosition: vec2<f32>,
  @location(4) vMaxDist: f32,
  @location(5) vCap: f32,
  @location(6) vJoin: f32,
  @location(7) vStrokeWeight: f32,
};

${uniforms$4}
@group(0) @binding(0) var<uniform> stroke: StrokeUniforms;
@group(1) @binding(0) var<uniform> model: ModelUniforms;
@group(2) @binding(0) var<uniform> camera: CameraUniforms;

struct StrokeVertex {
  position: vec3<f32>,
  tangentIn: vec3<f32>,
  tangentOut: vec3<f32>,
  color: vec4<f32>,
  weight: f32,
}

fn lineIntersection(aPoint: vec2f, aDir: vec2f, bPoint: vec2f, bDir: vec2f) -> vec2f {
  // Rotate and translate so a starts at the origin and goes out to the right
  var bMutPoint = bPoint;
  bMutPoint -= aPoint;
  var rotatedBFrom = vec2<f32>(
    bMutPoint.x*aDir.x + bMutPoint.y*aDir.y,
    bMutPoint.y*aDir.x - bMutPoint.x*aDir.y
  );
  var bTo = bMutPoint + bDir;
  var rotatedBTo = vec2<f32>(
    bTo.x*aDir.x + bTo.y*aDir.y,
    bTo.y*aDir.x - bTo.x*aDir.y
  );
  var intersectionDistance =
    rotatedBTo.x + (rotatedBFrom.x - rotatedBTo.x) * rotatedBTo.y /
    (rotatedBTo.y - rotatedBFrom.y);
  return aPoint + aDir * intersectionDistance;
}

@vertex
fn main(input: StrokeVertexInput) -> StrokeVertexOutput {
  HOOK_beforeVertex();
  var output: StrokeVertexOutput;
  let simpleLines = (stroke.uSimpleLines != 0.);
  if (!simpleLines) {
    if (all(input.aTangentIn == vec3<f32>()) != all(input.aTangentOut == vec3<f32>())) {
      output.vCap = 1.;
    } else {
      output.vCap = 0.;
    }
    let conditionA = any(input.aTangentIn != vec3<f32>());
    let conditionB = any(input.aTangentOut != vec3<f32>());
    let conditionC = any(input.aTangentIn != input.aTangentOut);
    if (conditionA && conditionB && conditionC) {
      output.vJoin = 1.;
    } else {
      output.vJoin = 0.;
    }
  }
  var lineColor: vec4<f32>;
  if (stroke.uUseLineColor != 0.) {
    lineColor = input.aVertexColor;
  } else {
    lineColor = model.uMaterialColor;
  }
  var inputs = StrokeVertex(
    input.aPosition.xyz,
    input.aTangentIn,
    input.aTangentOut,
    lineColor,
    stroke.uStrokeWeight
  );

// @p5 ifdef StrokeVertex getObjectInputs
  inputs = HOOK_getObjectInputs(inputs);
// @p5 endif

// @p5 ifdef StrokeVertex getWorldInputs
  inputs.position = (model.uModelMatrix * vec4<f32>(inputs.position, 1.)).xyz;
  inputs.tangentIn = (model.uModelMatrix * vec4<f32>(input.aTangentIn, 1.)).xyz;
  inputs.tangentOut = (model.uModelMatrix * vec4<f32>(input.aTangentOut, 1.)).xyz;
  inputs = HOOK_getWorldInputs(inputs);
// @p5 endif

// @p5 ifdef StrokeVertex getWorldInputs
  // Already multiplied by the model matrix, just apply view
  inputs.position = (model.uViewMatrix * vec4<f32>(inputs.position, 1.)).xyz;
  inputs.tangentIn = (model.uViewMatrix * vec4<f32>(input.aTangentIn, 0.)).xyz;
  inputs.tangentOut = (model.uViewMatrix * vec4<f32>(input.aTangentOut, 0.)).xyz;
// @p5 endif
// @p5 ifndef StrokeVertex getWorldInputs
  // Apply both at once
  inputs.position = (model.uModelViewMatrix * vec4<f32>(inputs.position, 1.)).xyz;
  inputs.tangentIn = (model.uModelViewMatrix * vec4<f32>(input.aTangentIn, 0.)).xyz;
  inputs.tangentOut = (model.uModelViewMatrix * vec4<f32>(input.aTangentOut, 0.)).xyz;
// @p5 endif
// @p5 ifdef StrokeVertex getCameraInputs
  inputs = HOOK_getCameraInputs(inputs);
// @p5 endif

  var posp = vec4<f32>(inputs.position, 1.);
  var posqIn = vec4<f32>(inputs.position + inputs.tangentIn, 1.);
  var posqOut = vec4<f32>(inputs.position + inputs.tangentOut, 1.);
  output.vStrokeWeight = inputs.weight;

  var facingCamera = pow(
    // The word space tangent's z value is 0 if it's facing the camera
    abs(normalize(posqIn-posp).z),

    // Using pow() here to ramp 'facingCamera' up from 0 to 1 really quickly
    // so most lines get scaled and don't get clipped
    0.25
  );

  // Moving vertices slightly toward the camera
  // to avoid depth-fighting with the fill triangles.
  // A mix of scaling and offsetting is used based on distance
  // Discussion here:
  // https://github.com/processing/p5.js/issues/7200

  // using a scale <1 moves the lines towards nearby camera
  // in order to prevent popping effects due to half of
  // the line disappearing behind the geometry faces.
  var zDistance = -posp.z;
  var distanceFactor = smoothstep(0., 800., zDistance);

  // Discussed here:
  // http://www.opengl.org/discussion_boards/ubbthreads.php?ubb=showflat&Number=252848
  var scale = mix(1., 0.995, facingCamera);
  var dynamicScale = mix(scale, 1.0, distanceFactor); // Closer = more scale, farther = less

  posp = vec4<f32>(posp.xyz * dynamicScale, posp.w);
  posqIn = vec4<f32>(posqIn.xyz * dynamicScale, posqIn.w);
  posqOut= vec4<f32>(posqOut.xyz * dynamicScale, posqOut.w);

  // Moving vertices slightly toward camera when far away
  // https://github.com/processing/p5.js/issues/6956
  var zOffset = mix(0., -1., facingCamera);
  var dynamicZAdjustment = mix(0., zOffset, distanceFactor); // Closer = less zAdjustment, farther = more

  posp.z -= dynamicZAdjustment;
  posqIn.z -= dynamicZAdjustment;
  posqOut.z -= dynamicZAdjustment;

  var p = camera.uProjectionMatrix * posp;
  var qIn = camera.uProjectionMatrix * posqIn;
  var qOut = camera.uProjectionMatrix * posqOut;

  var tangentIn = normalize((qIn.xy * p.w - p.xy * qIn.w) * camera.uViewport.zw);
  var tangentOut = normalize((qOut.xy * p.w - p.xy * qOut.w) * camera.uViewport.zw);

  var curPerspScale = vec2<f32>();
  if (camera.uPerspective == 1) {
    // Perspective ---
    // convert from world to clip by multiplying with projection scaling factor
    // to get the right thickness (see https://github.com/processing/processing/issues/5182)

    // The y value of the projection matrix may be flipped if rendering to a Framebuffer.
    // Multiplying again by its sign here negates the flip to get just the scale.
    curPerspScale = (camera.uProjectionMatrix * vec4(1., sign(camera.uProjectionMatrix[1][1]), 0., 0.)).xy;
  } else {
    // No Perspective ---
    // multiply by W (to cancel out division by W later in the pipeline) and
    // convert from screen to clip (derived from clip to screen above)
    curPerspScale = p.w / (0.5 * camera.uViewport.zw);
  }

  var offset = vec2<f32>();
  if (output.vJoin == 1. && !simpleLines) {
    output.vTangent = normalize(tangentIn + tangentOut);
    var normalIn = vec2<f32>(-tangentIn.y, tangentIn.x);
    var normalOut = vec2<f32>(-tangentOut.y, tangentOut.x);
    var side = sign(input.aSide);
    var sideEnum = abs(input.aSide);

    // We generate vertices for joins on either side of the centerline, but
    // the "elbow" side is the only one needing a join. By not setting the
    // offset for the other side, all its vertices will end up in the same
    // spot and not render, effectively discarding it.
    if (sign(dot(tangentOut, vec2<f32>(-tangentIn.y, tangentIn.x))) != side) {
      // Side enums:
      //   1: the side going into the join
      //   2: the middle of the join
      //   3: the side going out of the join
      if (sideEnum == 2.) {
        // Calculate the position + tangent on either side of the join, and
        // find where the lines intersect to find the elbow of the join
        var c = (posp.xy / posp.w + vec2<f32>(1.)) * 0.5 * camera.uViewport.zw;

        var intersection = lineIntersection(
          c + (side * normalIn * inputs.weight / 2.),
          tangentIn,
          c + (side * normalOut * inputs.weight / 2.),
          tangentOut
        );
        offset = intersection - c;


        // When lines are thick and the angle of the join approaches 180, the
        // elbow might be really far from the center. We'll apply a limit to
        // the magnitude to avoid lines going across the whole screen when this
        // happens.
        var mag = length(offset);
        var maxMag = 3. * inputs.weight;
        if (mag > maxMag) {
          offset *= maxMag / mag;
        }
      } else if (sideEnum == 1.) {
          offset = side * normalIn * inputs.weight / 2.;
      } else if (sideEnum == 3.) {
          offset = side * normalOut * inputs.weight / 2.;
      }
    }
    if (stroke.uStrokeJoin == 2) {
      var avgNormal = vec2<f32>(-output.vTangent.y, output.vTangent.x);
      output.vMaxDist = abs(dot(avgNormal, normalIn * inputs.weight / 2.));
    } else {
      output.vMaxDist = inputs.weight / 2.;
    }
  } else {
    var tangent: vec2<f32>;
    if (all(input.aTangentIn == vec3<f32>())) {
      tangent = tangentOut;
    } else {
      tangent = tangentIn;
    }
    output.vTangent = tangent;
    var normal = vec2<f32>(-tangent.y, tangent.x);

    var normalOffset = sign(input.aSide);
    // Caps will have side values of -2 or 2 on the edge of the cap that
    // extends out from the line
    var tangentOffset = abs(input.aSide) - 1.;
    offset = (normal * normalOffset + tangent * tangentOffset) *
      inputs.weight * 0.5;
    output.vMaxDist = inputs.weight / 2.;
  }
  output.vCenter = p.xy;
  output.vPosition = output.vCenter + offset;
  output.vColor = inputs.color;

  output.Position = vec4<f32>(
    p.xy + offset.xy * curPerspScale,
    p.zw
  );
  HOOK_afterVertex();
  return output;
}`;

  const lineFragmentShader = `
struct StrokeFragmentInput {
  @location(0) vColor: vec4<f32>,
  @location(1) vTangent: vec2<f32>,
  @location(2) vCenter: vec2<f32>,
  @location(3) vPosition: vec2<f32>,
  @location(4) vMaxDist: f32,
  @location(5) vCap: f32,
  @location(6) vJoin: f32,
  @location(7) vStrokeWeight: f32,
}

${uniforms$4}
@group(0) @binding(0) var<uniform> stroke: StrokeUniforms;
@group(1) @binding(0) var<uniform> model: ModelUniforms;
@group(2) @binding(0) var<uniform> camera: CameraUniforms;


fn distSquared(a: vec2<f32>, b: vec2<f32>) -> f32 {
  return dot(b - a, b - a);
}

struct Inputs {
  color: vec4<f32>,
  tangent: vec2<f32>,
  center: vec2<f32>,
  position: vec2<f32>,
  strokeWeight: f32,
}

@fragment
fn main(input: StrokeFragmentInput) -> @location(0) vec4<f32> {
  HOOK_beforeFragment();

  var inputs: Inputs;
  inputs.color = input.vColor;
  inputs.tangent = input.vTangent;
  inputs.center = input.vCenter;
  inputs.position = input.vPosition;
  inputs.strokeWeight = input.vStrokeWeight;
  inputs = HOOK_getPixelInputs(inputs);

  if (input.vCap > 0.) {
    if (
      stroke.uStrokeCap == STROKE_CAP_ROUND &&
      HOOK_shouldDiscard(distSquared(inputs.position, inputs.center) > inputs.strokeWeight * inputs.strokeWeight * 0.25)
    ) {
      discard;
    } else if (
      stroke.uStrokeCap == STROKE_CAP_SQUARE &&
      HOOK_shouldDiscard(dot(inputs.position - inputs.center, inputs.tangent) > 0.)
    ) {
      discard;
    } else if (HOOK_shouldDiscard(false)) {
      discard;
    }
  } else if (input.vJoin > 0.) {
    if (
      stroke.uStrokeJoin == STROKE_JOIN_ROUND &&
      HOOK_shouldDiscard(distSquared(inputs.position, inputs.center) > inputs.strokeWeight * inputs.strokeWeight * 0.25)
    ) {
      discard;
    } else if (stroke.uStrokeJoin == STROKE_JOIN_BEVEL) {
      let normal = vec2<f32>(-inputs.tangent.y, -inputs.tangent.x);
      if (HOOK_shouldDiscard(abs(dot(inputs.position - inputs.center, normal)) > input.vMaxDist)) {
        discard;
      }
    } else if (HOOK_shouldDiscard(false)) {
      discard;
    }
  }
  var col = HOOK_getFinalColor(inputs.color, vec2<f32>(0.0, 0.0));
  col = vec4<f32>(col.rgb, 1.0) * col.a;
  HOOK_afterFragment();
  return vec4<f32>(col);
}
`;

  const uniforms$3 = `
// Group 0: Material Properties
struct MaterialUniforms {
  uUseVertexColor: u32,
  uHasSetAmbient: u32,
  uAmbientColor: vec3<f32>,
  uSpecularMatColor: vec4<f32>,
  uAmbientMatColor: vec4<f32>,
  uEmissiveMatColor: vec4<f32>,
  uTint: vec4<f32>,
  isTexture: u32,
  uSpecular: u32,
  uShininess: f32,
  uMetallic: f32,
}

// Group 0: Lighting
struct LightingUniforms {
  uDirectionalLightCount: i32,
  uLightingDirection: array<vec3<f32>, 5>,
  uDirectionalDiffuseColors: array<vec3<f32>, 5>,
  uDirectionalSpecularColors: array<vec3<f32>, 5>,
  uPointLightCount: i32,
  uPointLightLocation: array<vec3<f32>, 5>,
  uPointLightDiffuseColors: array<vec3<f32>, 5>,
  uPointLightSpecularColors: array<vec3<f32>, 5>,
  uSpotLightCount: i32,
  uSpotLightAngle: vec4<f32>,
  uSpotLightConc: vec4<f32>,
  uSpotLightDiffuseColors: array<vec3<f32>, 4>,
  uSpotLightSpecularColors: array<vec3<f32>, 4>,
  uSpotLightLocation: array<vec3<f32>, 4>,
  uSpotLightDirection: array<vec3<f32>, 4>,
  uConstantAttenuation: f32,
  uLinearAttenuation: f32,
  uQuadraticAttenuation: f32,
  uUseImageLight: u32,
  uUseLighting: u32,
}

// Group 1: Model Transform
struct ModelUniforms {
// @p5 ifdef Vertex getWorldInputs
  uModelMatrix: mat4x4<f32>,
  uModelNormalMatrix: mat3x3<f32>,
// @p5 endif
// @p5 ifndef Vertex getWorldInputs
  uModelViewMatrix: mat4x4<f32>,
  uNormalMatrix: mat3x3<f32>,
// @p5 endif
  uMaterialColor: vec4<f32>,
}

// Group 2: Camera and Projection
struct CameraUniforms {
  uViewMatrix: mat4x4<f32>,
  uProjectionMatrix: mat4x4<f32>,
  uCameraNormalMatrix: mat3x3<f32>,
}
`;

  const materialVertexShader = `
struct VertexInput {
  @location(0) aPosition: vec3<f32>,
  @location(1) aNormal: vec3<f32>,
  @location(2) aTexCoord: vec2<f32>,
  @location(3) aVertexColor: vec4<f32>,
};

struct VertexOutput {
  @builtin(position) Position: vec4<f32>,
  @location(0) vNormal: vec3<f32>,
  @location(1) vTexCoord: vec2<f32>,
  @location(2) vViewPosition: vec3<f32>,
  @location(4) vColor: vec4<f32>,
};

${uniforms$3}
@group(0) @binding(0) var<uniform> material: MaterialUniforms;
@group(0) @binding(1) var<uniform> lighting: LightingUniforms;
@group(1) @binding(0) var<uniform> model: ModelUniforms;
@group(2) @binding(0) var<uniform> camera: CameraUniforms;

struct Vertex {
  position: vec3<f32>,
  normal: vec3<f32>,
  texCoord: vec2<f32>,
  color: vec4<f32>,
}

@vertex
fn main(input: VertexInput) -> VertexOutput {
  HOOK_beforeVertex();
  var output: VertexOutput;

  let useVertexColor = (material.uUseVertexColor != 0 && input.aVertexColor.x >= 0.0);
  var inputs = Vertex(
    input.aPosition,
    input.aNormal,
    input.aTexCoord,
    select(model.uMaterialColor, input.aVertexColor, useVertexColor)
  );

// @p5 ifdef Vertex getObjectInputs
  inputs = HOOK_getObjectInputs(inputs);
// @p5 endif

// @p5 ifdef Vertex getWorldInputs
  inputs.position = (model.uModelMatrix * vec4<f32>(inputs.position, 1.0)).xyz;
  inputs.normal = model.uModelNormalMatrix * inputs.normal;
  inputs = HOOK_getWorldInputs(inputs);
// @p5 endif

// @p5 ifdef Vertex getWorldInputs
  // Already multiplied by the model matrix, just apply view
  inputs.position = (camera.uViewMatrix * vec4<f32>(inputs.position, 1.0)).xyz;
  inputs.normal = camera.uCameraNormalMatrix * inputs.normal;
// @p5 endif
// @p5 ifndef Vertex getWorldInputs
  // Apply both at once
  inputs.position = (model.uModelViewMatrix * vec4<f32>(inputs.position, 1.0)).xyz;
  inputs.normal = model.uNormalMatrix * inputs.normal;
// @p5 endif

// @p5 ifdef Vertex getCameraInputs
  inputs = HOOK_getCameraInputs(inputs);
// @p5 endif

  output.vViewPosition = inputs.position;
  output.vTexCoord = inputs.texCoord;
  output.vNormal = normalize(inputs.normal);
  output.vColor = inputs.color;

  output.Position = camera.uProjectionMatrix * vec4<f32>(inputs.position, 1.0);

  HOOK_afterVertex();
  return output;
}
`;

  const materialFragmentShader = `
struct FragmentInput {
  @location(0) vNormal: vec3<f32>,
  @location(1) vTexCoord: vec2<f32>,
  @location(2) vViewPosition: vec3<f32>,
  @location(4) vColor: vec4<f32>,
};

${uniforms$3}
@group(0) @binding(0) var<uniform> material: MaterialUniforms;
@group(0) @binding(1) var<uniform> lighting: LightingUniforms;
@group(0) @binding(2) var uSampler: texture_2d<f32>;
@group(0) @binding(3) var uSampler_sampler: sampler;
@group(0) @binding(4) var environmentMapDiffused: texture_2d<f32>;
@group(0) @binding(5) var environmentMapDiffused_sampler: sampler;
@group(0) @binding(6) var environmentMapSpecular: texture_2d<f32>;
@group(0) @binding(7) var environmentMapSpecular_sampler: sampler;
@group(1) @binding(0) var<uniform> model: ModelUniforms;
@group(2) @binding(0) var<uniform> camera: CameraUniforms;

struct ColorComponents {
  baseColor: vec3<f32>,
  opacity: f32,
  ambientColor: vec3<f32>,
  specularColor: vec3<f32>,
  diffuse: vec3<f32>,
  ambient: vec3<f32>,
  specular: vec3<f32>,
  emissive: vec3<f32>,
}

struct Inputs {
  normal: vec3<f32>,
  texCoord: vec2<f32>,
  ambientLight: vec3<f32>,
  ambientMaterial: vec3<f32>,
  specularMaterial: vec3<f32>,
  emissiveMaterial: vec3<f32>,
  color: vec4<f32>,
  shininess: f32,
  metalness: f32,
}


struct LightResult {
  diffuse: vec3<f32>,
  specular: vec3<f32>,
}
struct LightIntensityResult {
  diffuse: f32,
  specular: f32,
}

const specularFactor = 2.0;
const diffuseFactor = 0.73;
const PI = 3.14159265359;

fn mapTextureToNormal(v: vec3<f32>) -> vec2<f32> {
  // x = r sin(phi) cos(theta)
  // y = r cos(phi)
  // z = r sin(phi) sin(theta)
  let phi = acos(v.y);
  // if phi is 0, then there are no x, z components
  var theta = 0.0;
  // else
  theta = acos(v.x / sin(phi));
  let sinTheta = v.z / sin(phi);
  if (sinTheta < 0.0) {
    // Turn it into -theta, but in the 0-2PI range
    theta = 2.0 * PI - theta;
  }
  theta = theta / (2.0 * PI);
  let phiNorm = phi / PI;

  let angles = vec2<f32>(fract(theta + 0.25), 1.0 - phiNorm);
  return angles;
}

fn calculateImageDiffuse(vNormal: vec3<f32>, vViewPosition: vec3<f32>, metallic: f32) -> vec3<f32> {
  // make 2 seperate builds
  let worldCameraPosition = vec3<f32>(0.0, 0.0, 0.0);  // hardcoded world camera position
  let worldNormal = normalize(vNormal * camera.uCameraNormalMatrix);
  let newTexCoord = mapTextureToNormal(worldNormal);
  let texture = textureSample(environmentMapDiffused, environmentMapDiffused_sampler, newTexCoord);
  // this is to make the darker sections more dark
  // png and jpg usually flatten the brightness so it is to reverse that
  return mix(smoothstep(vec3<f32>(0.0), vec3<f32>(1.0), texture.xyz), vec3<f32>(0.0), metallic);
}

fn calculateImageSpecular(vNormal: vec3<f32>, vViewPosition: vec3<f32>, shininess: f32, metallic: f32) -> vec3<f32> {
  let worldCameraPosition = vec3<f32>(0.0, 0.0, 0.0);
  let worldNormal = normalize(vNormal);
  let lightDirection = normalize(vViewPosition - worldCameraPosition);
  let R = reflect(lightDirection, worldNormal) * camera.uCameraNormalMatrix;
  let newTexCoord = mapTextureToNormal(R);

  // In p5js the range of shininess is >= 1,
  // Therefore roughness range will be ([0,1]*8)*20 or [0, 160]
  // The factor of 8 is because currently the getSpecularTexture
  // only calculated 8 different levels of roughness
  // The factor of 20 is just to spread up this range so that,
  // [1, max] of shininess is converted to [0,160] of roughness
  let roughness = 20.0 / shininess;
  let outColor = textureSampleLevel(environmentMapSpecular, environmentMapSpecular_sampler, newTexCoord, roughness * 8.0 - 1.);

  // this is to make the darker sections more dark
  // png and jpg usually flatten the brightness so it is to reverse that
  return mix(
    pow(outColor.xyz, vec3<f32>(10.0)),
    pow(outColor.xyz, vec3<f32>(1.2)),
    metallic
  );
}

fn phongSpecular(
  lightDirection: vec3<f32>,
  viewDirection: vec3<f32>,
  surfaceNormal: vec3<f32>,
  shininess: f32
) -> f32 {
  let R = reflect(lightDirection, surfaceNormal);
  return pow(max(0.0, dot(R, viewDirection)), shininess);
}

fn lambertDiffuse(lightDirection: vec3<f32>, surfaceNormal: vec3<f32>) -> f32 {
  return max(0.0, dot(-lightDirection, surfaceNormal));
}

fn singleLight(
  viewDirection: vec3<f32>,
  normal: vec3<f32>,
  lightVector: vec3<f32>,
  shininess: f32,
  metallic: f32
) -> LightIntensityResult {
  let lightDir = normalize(lightVector);
  let specularIntensity = mix(1.0, 0.4, metallic);
  let diffuseIntensity = mix(1.0, 0.1, metallic);
  let diffuse = lambertDiffuse(lightDir, normal) * diffuseIntensity;
  let specular = select(
    0.,
    phongSpecular(lightDir, viewDirection, normal, shininess) * specularIntensity,
    material.uSpecular == 1
  );
  return LightIntensityResult(diffuse, specular);
}

fn totalLight(
  modelPosition: vec3<f32>,
  normal: vec3<f32>,
  shininess: f32,
  metallic: f32
) -> LightResult {
  var totalSpecular = vec3<f32>(0.0, 0.0, 0.0);
  var totalDiffuse = vec3<f32>(0.0, 0.0, 0.0);

  if (lighting.uUseLighting == 0) {
    return LightResult(vec3<f32>(1.0, 1.0, 1.0), totalSpecular);
  }

  let viewDirection = normalize(-modelPosition);

  for (var j = 0; j < 5; j++) {
    if (j < lighting.uDirectionalLightCount) {
      let lightVector = (camera.uViewMatrix * vec4<f32>(
        lighting.uLightingDirection[j],
        0.0
      )).xyz;
      let lightColor = lighting.uDirectionalDiffuseColors[j];
      let specularColor = lighting.uDirectionalSpecularColors[j];
      let result = singleLight(viewDirection, normal, lightVector, shininess, metallic);
      totalDiffuse += result.diffuse * lightColor;
      totalSpecular += result.specular * specularColor;
    }

    if (j < lighting.uPointLightCount) {
      let lightPosition = (camera.uViewMatrix * vec4<f32>(
        lighting.uPointLightLocation[j],
        1.0
      )).xyz;
      let lightVector = modelPosition - lightPosition;
      let lightDistance = length(lightVector);
      let lightFalloff = 1.0 / (
        lighting.uConstantAttenuation +
        lightDistance * lighting.uLinearAttenuation +
        lightDistance * lightDistance * lighting.uQuadraticAttenuation
      );
      let lightColor = lighting.uPointLightDiffuseColors[j] * lightFalloff;
      let specularColor = lighting.uPointLightSpecularColors[j] * lightFalloff;
      let result = singleLight(viewDirection, normal, lightVector, shininess, metallic);
      totalDiffuse += result.diffuse * lightColor;
      totalSpecular += result.specular * specularColor;
    }

    if (j < lighting.uSpotLightCount) {
      let lightPosition = (camera.uViewMatrix * vec4<f32>(
        lighting.uSpotLightLocation[j],
        1.0
      )).xyz;
      let lightVector = modelPosition - lightPosition;
      let lightDistance = length(lightVector);
      var lightFalloff = 1.0 / (
        lighting.uConstantAttenuation +
        lightDistance * lighting.uLinearAttenuation +
        lightDistance * lightDistance * lighting.uQuadraticAttenuation
      );
      let lightDirection = (camera.uViewMatrix * vec4<f32>(
        lighting.uSpotLightDirection[j],
        0.0
      )).xyz;
      let spotDot = dot(normalize(lightVector), normalize(lightDirection));
      let spotFalloff = select(
        0.0,
        pow(spotDot, lighting.uSpotLightConc[j]),
        spotDot < lighting.uSpotLightAngle[j]
      );
      lightFalloff *= spotFalloff;
      let lightColor = lighting.uSpotLightDiffuseColors[j];
      let specularColor = lighting.uSpotLightSpecularColors[j];
      let result = singleLight(viewDirection, normal, lightVector, shininess, metallic);
      totalDiffuse += result.diffuse * lightColor;
      totalSpecular += result.specular * specularColor;
    }
  }

  // Image light contribution
  if (lighting.uUseImageLight != 0) {
    totalDiffuse += calculateImageDiffuse(normal, modelPosition, metallic);
    totalSpecular += calculateImageSpecular(normal, modelPosition, shininess, metallic);
  }

  return LightResult(
    totalDiffuse * diffuseFactor,
    totalSpecular * specularFactor
  );
}

@fragment
fn main(input: FragmentInput) -> @location(0) vec4<f32> {
  HOOK_beforeFragment();

  let color = select(
    input.vColor,
    textureSample(uSampler, uSampler_sampler, input.vTexCoord) * (material.uTint/255.0),
    material.isTexture == 1
  ); // TODO: check isTexture and apply tint
  var inputs = Inputs(
    normalize(input.vNormal),
    input.vTexCoord,
    material.uAmbientColor,
    select(color.rgb, material.uAmbientMatColor.rgb, material.uHasSetAmbient == 1),
    material.uSpecularMatColor.rgb,
    material.uEmissiveMatColor.rgb,
    color,
    material.uShininess,
    material.uMetallic
  );
  inputs = HOOK_getPixelInputs(inputs);

  let light = totalLight(
    input.vViewPosition,
    inputs.normal,
    inputs.shininess,
    inputs.metalness
  );

  let baseColor = inputs.color;
  let components = ColorComponents(
    baseColor.rgb,
    baseColor.a,
    inputs.ambientMaterial,
    inputs.specularMaterial,
    light.diffuse,
    inputs.ambientLight,
    light.specular,
    inputs.emissiveMaterial
  );

   var outColor = HOOK_getFinalColor(
     HOOK_combineColors(components), input.vTexCoord
   );
  outColor = vec4<f32>(outColor.rgb * outColor.a, outColor.a);
  HOOK_afterFragment();
  return outColor;
}
`;

  const uniforms$2 = `
// Group 0: Font Properties
struct FontUniforms {
  uStrokeImageSize: vec2<i32>,
  uCellsImageSize: vec2<i32>,
  uGridImageSize: vec2<i32>,
  uGridOffset: vec2<i32>,
  uGridSize: vec2<i32>,
  uGlyphRect: vec4<f32>,
  uGlyphOffset: f32,
  uMaterialColor: vec4<f32>,
}

// Group 1: Model Transform
struct ModelUniforms {
  uModelViewMatrix: mat4x4<f32>,
}

// Group 2: Camera and Projection
struct CameraUniforms {
  uProjectionMatrix: mat4x4<f32>,
}
`;

  const fontVertexShader = `
struct VertexInput {
  @location(0) aPosition: vec3<f32>,
  @location(1) aTexCoord: vec2<f32>,
};

struct VertexOutput {
  @builtin(position) Position: vec4<f32>,
  @location(0) vTexCoord: vec2<f32>,
};

${uniforms$2}
@group(0) @binding(0) var<uniform> font: FontUniforms;
@group(1) @binding(0) var<uniform> model: ModelUniforms;
@group(2) @binding(0) var<uniform> camera: CameraUniforms;

@vertex
fn main(input: VertexInput) -> VertexOutput {
  var output: VertexOutput;
  var positionVec4 = vec4<f32>(input.aPosition, 1.0);

  // scale by the size of the glyph's rectangle
  positionVec4.x = positionVec4.x * (font.uGlyphRect.z - font.uGlyphRect.x);
  positionVec4.y = positionVec4.y * (font.uGlyphRect.w - font.uGlyphRect.y);

  // Expand glyph bounding boxes by 1px on each side to give a bit of room
  // for antialiasing
  let newOrigin = (model.uModelViewMatrix * vec4<f32>(0.0, 0.0, 0.0, 1.0)).xyz;
  let newDX = (model.uModelViewMatrix * vec4<f32>(1.0, 0.0, 0.0, 1.0)).xyz;
  let newDY = (model.uModelViewMatrix * vec4<f32>(0.0, 1.0, 0.0, 1.0)).xyz;
  let pixelScale = vec2<f32>(
    1.0 / length(newOrigin - newDX),
    1.0 / length(newOrigin - newDY)
  );
  let offset = pixelScale * normalize(input.aTexCoord - vec2<f32>(0.5, 0.5));
  let textureOffset = offset * (1.0 / vec2<f32>(
    font.uGlyphRect.z - font.uGlyphRect.x,
    font.uGlyphRect.w - font.uGlyphRect.y
  ));

  // move to the corner of the glyph
  positionVec4.x = positionVec4.x + font.uGlyphRect.x;
  positionVec4.y = positionVec4.y + font.uGlyphRect.y;

  // move to the letter's line offset
  positionVec4.x = positionVec4.x + font.uGlyphOffset;

  positionVec4.x = positionVec4.x + offset.x;
  positionVec4.y = positionVec4.y + offset.y;

  output.Position = camera.uProjectionMatrix * model.uModelViewMatrix * positionVec4;
  output.vTexCoord = input.aTexCoord + textureOffset;

  return output;
}
`;

  const fontFragmentShader = `
struct FragmentInput {
  @location(0) vTexCoord: vec2<f32>,
};

${uniforms$2}
@group(0) @binding(0) var<uniform> font: FontUniforms;
@group(0) @binding(1) var uSamplerStrokes: texture_2d<f32>;
@group(0) @binding(2) var uSamplerStrokes_sampler: sampler;
@group(0) @binding(3) var uSamplerRowStrokes: texture_2d<f32>;
@group(0) @binding(4) var uSamplerRowStrokes_sampler: sampler;
@group(0) @binding(5) var uSamplerRows: texture_2d<f32>;
@group(0) @binding(6) var uSamplerRows_sampler: sampler;
@group(0) @binding(7) var uSamplerColStrokes: texture_2d<f32>;
@group(0) @binding(8) var uSamplerColStrokes_sampler: sampler;
@group(0) @binding(9) var uSamplerCols: texture_2d<f32>;
@group(0) @binding(10) var uSamplerCols_sampler: sampler;
@group(1) @binding(0) var<uniform> model: ModelUniforms;
@group(2) @binding(0) var<uniform> camera: CameraUniforms;

// some helper functions
fn ROUND_f32(v: f32) -> i32 { return i32(floor(v + 0.5)); }
fn ROUND_vec2(v: vec2<f32>) -> vec2<i32> { return vec2<i32>(floor(v + 0.5)); }
fn saturate_f32(v: f32) -> f32 { return clamp(v, 0.0, 1.0); }
fn saturate_vec2(v: vec2<f32>) -> vec2<f32> { return clamp(v, vec2<f32>(0.0), vec2<f32>(1.0)); }

fn mul_f32_i32(v1: f32, v2: i32) -> i32 {
  return i32(floor(v1 * f32(v2)));
}

fn mul_vec2_ivec2(v1: vec2<f32>, v2: vec2<i32>) -> vec2<i32> {
  return vec2<i32>(floor(v1 * vec2<f32>(v2) + 0.5));
}

// unpack a 16-bit integer from a float vec2
fn getInt16(v: vec2<f32>) -> i32 {
  let iv = ROUND_vec2(v * 255.0);
  return iv.x * 128 + iv.y;
}

const minDistance: f32 = 1.0/8192.0;
const hardness: f32 = 1.05; // amount of antialias

// the maximum number of curves in a glyph
const N: i32 = 250;

// retrieves an indexed pixel from a texture
fn getTexel(texture: texture_2d<f32>, samp: sampler, pos: i32, size: vec2<i32>) -> vec4<f32> {
  let width = size.x;
  let x = pos % width;
  let y = pos / width;

  return textureLoad(texture, vec2<i32>(x, y), 0);
}

fn calculateCrossings(p0: vec2<f32>, p1: vec2<f32>, p2: vec2<f32>, vTexCoord: vec2<f32>, pixelScale: vec2<f32>) -> array<vec2<f32>, 2> {
  // get the coefficients of the quadratic in t
  var a = p0 - p1 * 2.0 + p2;
  var b = p0 - p1;
  a = vec2<f32>(
    select(a.x, sign(a.x) * 1e-6, abs(a.x) < 1e-6),
    select(a.y, sign(a.y) * 1e-6, abs(a.y) < 1e-6)
  );
  b = vec2<f32>(
    select(b.x, sign(b.x) * 1e-6, abs(b.x) < 1e-6),
    select(b.y, sign(b.y) * 1e-6, abs(b.y) < 1e-6)
  );
  let c = p0 - vTexCoord;

  // found out which values of 't' it crosses the axes
  let surd = sqrt(max(vec2<f32>(0.0), b * b - a * c));
  let t1 = ((b - surd) / a).yx;
  let t2 = ((b + surd) / a).yx;

  // approximate straight lines to avoid rounding errors
  var t1_fixed = t1;
  var t2_fixed = t2;
  if (abs(a.y) < 0.001) {
    t1_fixed.x = c.y / (2.0 * b.y);
    t2_fixed.x = c.y / (2.0 * b.y);
  }

  if (abs(a.x) < 0.001) {
    t1_fixed.y = c.x / (2.0 * b.x);
    t2_fixed.y = c.x / (2.0 * b.x);
  }

  // plug into quadratic formula to find the coordinates of the crossings
  let C1 = ((a * t1_fixed - b * 2.0) * t1_fixed + c) * pixelScale;
  let C2 = ((a * t2_fixed - b * 2.0) * t2_fixed + c) * pixelScale;

  return array<vec2<f32>, 2>(C1, C2);
}

fn coverageX(p0: vec2<f32>, p1: vec2<f32>, p2: vec2<f32>, vTexCoord: vec2<f32>, pixelScale: vec2<f32>, coverage: ptr<function, vec2<f32>>, weight: ptr<function, vec2<f32>>) {
  let crossings = calculateCrossings(p0, p1, p2, vTexCoord, pixelScale);
  let C1 = crossings[0];
  let C2 = crossings[1];

  // determine on which side of the x-axis the points lie
  let y0 = p0.y > vTexCoord.y;
  let y1 = p1.y > vTexCoord.y;
  let y2 = p2.y > vTexCoord.y;

  // could we be under the curve (after t1)?
  if ((y1 && !y2) || (!y1 && y0)) {
    // add the coverage for t1
    (*coverage).x = (*coverage).x + saturate_f32(C1.x + 0.5);
    // calculate the anti-aliasing for t1
    (*weight).x = min((*weight).x, abs(C1.x));
  }

  // are we outside the curve (after t2)?
  if ((y1 && !y0) || (!y1 && y2)) {
    // subtract the coverage for t2
    (*coverage).x = (*coverage).x - saturate_f32(C2.x + 0.5);
    // calculate the anti-aliasing for t2
    (*weight).x = min((*weight).x, abs(C2.x));
  }
}

// this is essentially the same as coverageX, but with the axes swapped
fn coverageY(p0: vec2<f32>, p1: vec2<f32>, p2: vec2<f32>, vTexCoord: vec2<f32>, pixelScale: vec2<f32>, coverage: ptr<function, vec2<f32>>, weight: ptr<function, vec2<f32>>) {
  let crossings = calculateCrossings(p0, p1, p2, vTexCoord, pixelScale);
  let C1 = crossings[0];
  let C2 = crossings[1];

  let x0 = p0.x > vTexCoord.x;
  let x1 = p1.x > vTexCoord.x;
  let x2 = p2.x > vTexCoord.x;

  if ((x1 && !x2) || (!x1 && x0)) {
    (*coverage).y = (*coverage).y - saturate_f32(C1.y + 0.5);
    weight.y = min(weight.y, abs(C1.y));
  }

  if ((x1 && !x0) || (!x1 && x2)) {
    (*coverage).y = (*coverage).y + saturate_f32(C2.y + 0.5);
    (*weight).y = min((*weight).y, abs(C2.y));
  }
}

@fragment
fn main(input: FragmentInput) -> @location(0) vec4<f32> {
  // var pixelScale: vec2<f32>;
  var coverage: vec2<f32> = vec2<f32>(0.0);
  var weight: vec2<f32> = vec2<f32>(0.5);
  let pixelScale = hardness / fwidth(input.vTexCoord);

  // which grid cell is this pixel in?
  let gridCoord = vec2<i32>(floor(input.vTexCoord * vec2<f32>(font.uGridSize)));

  // intersect curves in this row
  {
    // the index into the row info bitmap
    let rowIndex = gridCoord.y + font.uGridOffset.y;
    // fetch the info texel
    let rowInfo = getTexel(uSamplerRows, uSamplerRows_sampler, rowIndex, font.uGridImageSize);
    // unpack the rowInfo
    let rowStrokeIndex = getInt16(rowInfo.xy);
    let rowStrokeCount = getInt16(rowInfo.zw);

    for (var iRowStroke = 0; iRowStroke < N; iRowStroke = iRowStroke + 1) {
      if (iRowStroke >= rowStrokeCount) {
        break;
      }

      // each stroke is made up of 3 points: the start and control point
      // and the start of the next curve.
      // fetch the indices of this pair of strokes:
      let strokeIndices = getTexel(uSamplerRowStrokes, uSamplerRowStrokes_sampler, rowStrokeIndex + iRowStroke, font.uCellsImageSize);

      // unpack the stroke index
      let strokePos = getInt16(strokeIndices.xy);

      // fetch the two strokes
      let stroke0 = getTexel(uSamplerStrokes, uSamplerStrokes_sampler, strokePos + 0, font.uStrokeImageSize);
      let stroke1 = getTexel(uSamplerStrokes, uSamplerStrokes_sampler, strokePos + 1, font.uStrokeImageSize);

      // calculate the coverage
      coverageX(stroke0.xy, stroke0.zw, stroke1.xy, input.vTexCoord, pixelScale, &coverage, &weight);
    }
  }

  // intersect curves in this column
  {
    let colIndex = gridCoord.x + font.uGridOffset.x;
    let colInfo = getTexel(uSamplerCols, uSamplerCols_sampler, colIndex, font.uGridImageSize);
    let colStrokeIndex = getInt16(colInfo.xy);
    let colStrokeCount = getInt16(colInfo.zw);

    for (var iColStroke = 0; iColStroke < N; iColStroke = iColStroke + 1) {
      if (iColStroke >= colStrokeCount) {
        break;
      }

      let strokeIndices = getTexel(uSamplerColStrokes, uSamplerColStrokes_sampler, colStrokeIndex + iColStroke, font.uCellsImageSize);

      let strokePos = getInt16(strokeIndices.xy);
      let stroke0 = getTexel(uSamplerStrokes, uSamplerStrokes_sampler, strokePos + 0, font.uStrokeImageSize);
      let stroke1 = getTexel(uSamplerStrokes, uSamplerStrokes_sampler, strokePos + 1, font.uStrokeImageSize);
      coverageY(stroke0.xy, stroke0.zw, stroke1.xy, input.vTexCoord, pixelScale, &coverage, &weight);
    }
  }

  weight = saturate_vec2(vec2<f32>(1.0) - weight * 2.0);
  let distance = max(weight.x + weight.y, minDistance); // manhattan approx.
  let antialias = abs(dot(coverage, weight) / distance);
  let cover = min(abs(coverage.x), abs(coverage.y));
  var outColor = vec4<f32>(font.uMaterialColor.rgb, 1.0) * font.uMaterialColor.a;
  outColor = outColor * saturate_f32(max(antialias, cover));
  return outColor;
}
`;

  const uniforms$1 = `
struct Uniforms {
  uModelViewMatrix: mat4x4<f32>,
  uProjectionMatrix: mat4x4<f32>,
};
`;

  const blitVertexShader = `
struct VertexInput {
  @location(0) aPosition: vec3<f32>,
  @location(1) aNormal: vec3<f32>,
  @location(2) aTexCoord: vec2<f32>,
  @location(3) aVertexColor: vec4<f32>,
};

struct VertexOutput {
  @builtin(position) Position: vec4<f32>,
  @location(0) vTexCoord: vec2<f32>,
};

${uniforms$1}
@group(0) @binding(0) var<uniform> uniforms: Uniforms;

@vertex
fn main(input: VertexInput) -> VertexOutput {
  var output: VertexOutput;
  output.vTexCoord = input.aTexCoord;
  let positionVec4 = vec4<f32>(input.aPosition, 1.0);
  output.Position = uniforms.uProjectionMatrix * uniforms.uModelViewMatrix * positionVec4;
  return output;
}
`;

  const blitFragmentShader = `
struct FragmentInput {
  @location(0) vTexCoord: vec2<f32>,
};

${uniforms$1}
@group(0) @binding(0) var<uniform> uniforms: Uniforms;
@group(0) @binding(1) var uSampler: texture_2d<f32>;
@group(0) @binding(2) var uSampler_sampler: sampler;

@fragment
fn main(input: FragmentInput) -> @location(0) vec4<f32> {
  return textureSample(uSampler, uSampler_sampler, input.vTexCoord);
}
`;

  // Based on https://github.com/stegu/webgl-noise/blob/22434e04d7753f7e949e8d724ab3da2864c17a0f/src/noise3D.glsl
  // MIT licensed, adapted for p5.strands and converted to WGSL

  var noiseWGSL = `fn mod289Vec3(x: vec3<f32>) -> vec3<f32> {
  return x - floor(x * (1.0 / 289.0)) * 289.0;
}

fn mod289Vec4(x: vec4<f32>) -> vec4<f32> {
  return x - floor(x * (1.0 / 289.0)) * 289.0;
}

fn permute(x: vec4<f32>) -> vec4<f32> {
  return mod289Vec4(((x*34.0)+10.0)*x);
}

fn taylorInvSqrt(r: vec4<f32>) -> vec4<f32> {
  return vec4<f32>(1.79284291400159) - vec4<f32>(0.85373472095314) * r;
}

fn baseNoise(v: vec3<f32>) -> f32 {
  let C = vec2<f32>(1.0/6.0, 1.0/3.0);
  let D = vec4<f32>(0.0, 0.5, 1.0, 2.0);

  // First corner
  var i = floor(v + dot(v, C.yyy));
  let x0 = v - i + dot(i, C.xxx);

  // Other corners
  let g = step(x0.yzx, x0.xyz);
  let l = vec3<f32>(1.0) - g;
  let i1 = min(g.xyz, l.zxy);
  let i2 = max(g.xyz, l.zxy);

  //   x0 = x0 - 0.0 + 0.0 * C.xxx;
  //   x1 = x0 - i1  + 1.0 * C.xxx;
  //   x2 = x0 - i2  + 2.0 * C.xxx;
  //   x3 = x0 - 1.0 + 3.0 * C.xxx;
  let x1 = x0 - i1 + C.xxx;
  let x2 = x0 - i2 + C.yyy; // 2.0*C.x = 1/3 = C.y
  let x3 = x0 - D.yyy;      // -1.0+3.0*C.x = -0.5 = -D.y

  // Permutations
  i = mod289Vec3(i);
  let p = permute( permute( permute(
          i.z + vec4<f32>(0.0, i1.z, i2.z, 1.0 ))
        + i.y + vec4<f32>(0.0, i1.y, i2.y, 1.0 ))
      + i.x + vec4<f32>(0.0, i1.x, i2.x, 1.0 ));

  // Gradients: 7x7 points over a square, mapped onto an octahedron.
  // The ring size 17*17 = 289 is close to a multiple of 49 (49*6 = 294)
  let n_ = 0.142857142857; // 1.0/7.0
  let ns = n_ * D.wyz - D.xzx;

  let j = p - 49.0 * floor(p * ns.z * ns.z);  //  mod(p,7*7)

  let x_ = floor(j * ns.z);
  let y_ = floor(j - 7.0 * x_ );    // mod(j,N)

  let x = x_ *ns.x + ns.yyyy;
  let y = y_ *ns.x + ns.yyyy;
  let h = vec4<f32>(1.0) - abs(x) - abs(y);

  let b0 = vec4<f32>( x.xy, y.xy );
  let b1 = vec4<f32>( x.zw, y.zw );

  //vec4 s0 = vec4(lessThan(b0,0.0))*2.0 - 1.0;
  //vec4 s1 = vec4(lessThan(b1,0.0))*2.0 - 1.0;
  let s0 = floor(b0)*2.0 + vec4<f32>(1.0);
  let s1 = floor(b1)*2.0 + vec4<f32>(1.0);
  let sh = -step(h, vec4<f32>(0.0));

  let a0 = b0.xzyw + s0.xzyw*sh.xxyy;
  let a1 = b1.xzyw + s1.xzyw*sh.zzww;

  let p0 = vec3<f32>(a0.xy, h.x);
  let p1 = vec3<f32>(a0.zw, h.y);
  let p2 = vec3<f32>(a1.xy, h.z);
  let p3 = vec3<f32>(a1.zw, h.w);

  //Normalise gradients
  let norm = taylorInvSqrt(vec4<f32>(dot(p0,p0), dot(p1,p1), dot(p2, p2), dot(p3,p3)));
  let p0_norm = p0 * norm.x;
  let p1_norm = p1 * norm.y;
  let p2_norm = p2 * norm.z;
  let p3_norm = p3 * norm.w;

  // Mix final noise value
  var m = max(vec4<f32>(0.5) - vec4<f32>(dot(x0,x0), dot(x1,x1), dot(x2,x2), dot(x3,x3)), vec4<f32>(0.0));
  m = m * m;
  return 105.0 * dot( m*m, vec4<f32>( dot(p0_norm,x0), dot(p1_norm,x1),
        dot(p2_norm,x2), dot(p3_norm,x3) ) );
}

fn noise(st: vec3<f32>, octaves: i32, ampFalloff: f32) -> f32 {
  var result = 0.0;
  var amplitude = 1.0;
  var frequency = 1.0;

  for (var i = 0; i < 8; i++) {
    if (i >= octaves) { break; }
    result += amplitude * baseNoise(st * frequency);
    frequency *= 2.0;
    amplitude *= ampFalloff;
  }
  return (result + 1.0) * 0.5;
}`;

  // _p5_hash: "Hash without Sine" by Dave Hoskins (https://www.shadertoy.com/view/4djSRW)
  // Mixing constants: R₂ sequence by Martin Roberts (https://extremelearning.com.au/unreasonable-effectiveness-of-quasirandom-sequences/)
  //   α₁ = 1/φ₂ = 0.7548776662 (plastic constant reciprocal)
  //   α₂ = 1/φ₂² = 0.5698402910
  //   1/φ = 0.6180339887 (golden ratio conjugate)
  //
  // Fragment shader version: pixelCoord is passed in from main via @builtin(position).

  var randomWGSL = `
var<private> _p5_randomCallIndex: i32 = 0;

fn _p5_hash(p: vec3<f32>) -> f32 {
  var p3 = fract(p * vec3<f32>(0.1031, 0.1030, 0.0973));
  p3 = p3 + dot(p3, p3.yxz + 33.33);
  return fract((p3.x + p3.y) * p3.z);
}

fn random(seed: f32, pixelCoord: vec2<f32>) -> f32 {
  let callIndex = f32(_p5_randomCallIndex);
  _p5_randomCallIndex = _p5_randomCallIndex + 1;
  let s = fract(seed * 0.7548776662);
  return _p5_hash(vec3<f32>(
    pixelCoord.x + s,
    pixelCoord.y + callIndex * 0.5698402910,
    s + callIndex * 0.6180339887
  ));
}
`;

  // _p5_hash: "Hash without Sine" by Dave Hoskins (https://www.shadertoy.com/view/4djSRW)
  // Mixing constants: R₂ sequence by Martin Roberts (https://extremelearning.com.au/unreasonable-effectiveness-of-quasirandom-sequences/)
  //   α₁ = 1/φ₂ = 0.7548776662 (plastic constant reciprocal)
  //   α₂ = 1/φ₂² = 0.5698402910
  //   1/φ = 0.6180339887 (golden ratio conjugate)
  //
  // Vertex shader version: vertexId is passed in from main via @builtin(vertex_index).

  var randomVertWGSL = `
var<private> _p5_randomCallIndex: i32 = 0;

fn _p5_hash(p: vec3<f32>) -> f32 {
  var p3 = fract(p * vec3<f32>(0.1031, 0.1030, 0.0973));
  p3 = p3 + dot(p3, p3.yxz + 33.33);
  return fract((p3.x + p3.y) * p3.z);
}

fn random(seed: f32, vertexId: f32) -> f32 {
  let callIndex = f32(_p5_randomCallIndex);
  _p5_randomCallIndex = _p5_randomCallIndex + 1;
  let s = fract(seed * 0.7548776662);
  return _p5_hash(vec3<f32>(
    vertexId + s,
    vertexId * 0.5698402910 + callIndex * 0.6180339887,
    s + callIndex * 0.7548776662
  ));
}
`;

  // _p5_hash: "Hash without Sine" by Dave Hoskins (https://www.shadertoy.com/view/4djSRW)
  // Mixing constants: R₂ sequence by Martin Roberts (https://extremelearning.com.au/unreasonable-effectiveness-of-quasirandom-sequences/)
  //   α₁ = 1/φ₂ = 0.7548776662 (plastic constant reciprocal)
  //   α₂ = 1/φ₂² = 0.5698402910
  //   1/φ = 0.6180339887 (golden ratio conjugate)
  //
  // Compute shader version: invocationId is passed in from main via @builtin(global_invocation_id).

  var randomComputeWGSL = `
var<private> _p5_randomCallIndex: i32 = 0;

fn _p5_hash(p: vec3<f32>) -> f32 {
  var p3 = fract(p * vec3<f32>(0.1031, 0.1030, 0.0973));
  p3 = p3 + dot(p3, p3.yxz + 33.33);
  return fract((p3.x + p3.y) * p3.z);
}

fn random(seed: f32, invocationId: vec3<u32>) -> f32 {
  let id = vec3<f32>(invocationId);
  let callIndex = f32(_p5_randomCallIndex);
  _p5_randomCallIndex = _p5_randomCallIndex + 1;
  let s = fract(seed * 0.7548776662);
  return _p5_hash(vec3<f32>(
    id.x + s,
    id.y + callIndex * 0.5698402910,
    id.z + s + callIndex * 0.6180339887
  ));
}
`;

  function internalError(errorMessage) {
      const prefixedMessage = `[p5.strands internal error]: ${errorMessage}`; 
      throw new Error(prefixedMessage);
  }

  function userError(errorType, errorMessage) {
      const prefixedMessage = `[p5.strands ${errorType}]: ${errorMessage}`;
      throw new Error(prefixedMessage);
  }

  function dimensionMismatchError(declaredDim,actualDim,varName){
      userError(
          'dimension mismatch',
      `Cannot assign a value of dimension ${actualDim} to \`${varName}\`, which expects dimension ${declaredDim}.`
      );
  }

  function getOrCreateNode(graph, node) {
    // const key = getNodeKey(node);
    // const existing = graph.cache.get(key);

    // if (existing !== undefined) {
      // return existing;
    // } else {
      const id = createNode(graph, node);
      // graph.cache.set(key, id);
      return id;
    // }
  }

  function createNodeData(data = {}) {
    const node = {
      nodeType: data.nodeType ?? null,
      baseType: data.baseType ?? null,
      dimension: data.dimension ?? null,
      opCode: data.opCode ?? null,
      value: data.value ?? null,
      identifier: data.identifier ?? null,
      statementType: data.statementType ?? null,
      swizzle: data.swizzle ?? null,
      dependsOn: Array.isArray(data.dependsOn) ? data.dependsOn : [],
      usedBy: Array.isArray(data.usedBy) ? data.usedBy : [],
      phiBlocks: Array.isArray(data.phiBlocks) ? data.phiBlocks : [],
    };
    validateNode(node);
    return node;
  }

  function getNodeDataFromID(graph, id) {
    return {
      id,
      nodeType: graph.nodeTypes[id],
      opCode: graph.opCodes[id],
      value: graph.values[id],
      identifier: graph.identifiers[id],
      dependsOn: graph.dependsOn[id],
      usedBy: graph.usedBy[id],
      phiBlocks: graph.phiBlocks[id],
      dimension: graph.dimensions[id],
      baseType: graph.baseTypes[id],
      statementType: graph.statementTypes[id],
      swizzle: graph.swizzles[id],
    }
  }

  function extractNodeTypeInfo(dag, nodeID) {
    return {
      baseType: dag.baseTypes[nodeID],
      dimension: dag.dimensions[nodeID],
      priority: BasePriority[dag.baseTypes[nodeID]],
    };
  }

  /////////////////////////////////
  // Private functions
  /////////////////////////////////
  function createNode(graph, node) {
    const id = graph.nextID++;
    graph.nodeTypes[id] = node.nodeType;
    graph.opCodes[id] = node.opCode;
    graph.values[id] = node.value;
    graph.identifiers[id] = node.identifier;
    graph.dependsOn[id] = node.dependsOn.slice();
    graph.usedBy[id] = node.usedBy;
    graph.phiBlocks[id] = node.phiBlocks.slice();
    graph.baseTypes[id] = node.baseType;
    graph.dimensions[id] = node.dimension;
    graph.statementTypes[id] = node.statementType;
    graph.swizzles[id] = node.swizzle;

    for (const dep of node.dependsOn) {
      if (!Array.isArray(graph.usedBy[dep])) {
        graph.usedBy[dep] = [];
      }
      graph.usedBy[dep].push(id);
    }
    return id;
  }

  function validateNode(node){
    const nodeType = node.nodeType;
    const requiredFields = NodeTypeRequiredFields[nodeType];
    if (requiredFields.length === 2) {
      internalError(`Required fields for node type '${NodeTypeToName[nodeType]}' not defined. Please add them to the utils.js file in p5.strands!`);
    }
    const missingFields = [];
    for (const field of requiredFields) {
      if (node[field] === null) {
        missingFields.push(field);
      }
    }
    if (node.dependsOn?.some(v => v === undefined)) {
      throw new Error('Undefined dependency!');
    }
    if (missingFields.length > 0) {
      internalError(`Missing fields ${missingFields.join(', ')} for a node type '${NodeTypeToName[nodeType]}'.`);
    }
  }

  function recordInBasicBlock(graph, blockID, nodeID) {
    if (nodeID === undefined) {
      internalError('undefined nodeID in `recordInBasicBlock()`');
    }
    if (blockID === undefined) {
      internalError('undefined blockID in `recordInBasicBlock()');
    }
    graph.blockInstructions[blockID] = graph.blockInstructions[blockID] || [];
    graph.blockInstructions[blockID].push(nodeID);
  }

  class StrandsNode {
    constructor(id, dimension, strandsContext) {
      this.id = id;
      this.strandsContext = strandsContext;
      this.dimension = dimension;
      this.structProperties = null;
      // Schema for struct storage buffers (set by uniformStorage when buffer has a struct layout).
      // When set, buf.get(idx) returns a field proxy instead of a scalar StrandsNode.
      this._schema = null;
      this.isStrandsNode = true;

      // Store original identifier for varying variables
      const dag = this.strandsContext.dag;
      const nodeData = getNodeDataFromID(dag, this.id);
      if (nodeData && nodeData.identifier) {
        this._originalIdentifier = nodeData.identifier;
      }
      if (nodeData) {
        this._originalBaseType = nodeData.baseType;
        this._originalDimension = nodeData.dimension;
      }
    }
    withStructProperties(properties) {
      this.structProperties = properties;
      return this;
    }
    copy() {
      return createStrandsNode(this.id, this.dimension, this.strandsContext);
    }
    typeInfo() {
      return {
        baseType: this._originalBaseType || BaseType.FLOAT,
        dimension: this.dimension
      };
    }
    bridge(value) {
      const { dag, cfg } = this.strandsContext;
      const orig = getNodeDataFromID(dag, this.id);
      const baseType = orig?.baseType ?? BaseType.FLOAT;

      let newValueID;
      if (value?.isStrandsNode) {
        newValueID = value.id;
      } else {
        const newVal = primitiveConstructorNode(
          this.strandsContext,
          { baseType, dimension: this.dimension },
          value
        );
        newValueID = newVal.id;
      }

      // For varying variables, we need both assignment generation AND a way to reference by identifier
      if (this._originalIdentifier) {
        const valueDim = value?.isStrandsNode
          ? value.dimension
          : (Array.isArray(value) ? value.length : 1);
        if (valueDim !== this._originalDimension && valueDim !== 1){
          dimensionMismatchError(
            this._originalDimension,
            valueDim,
            this._originalIdentifier
          );
        }
        // Create a variable node for the target (the varying variable)
        const { id: targetVarID } = variableNode(
          this.strandsContext,
          { baseType: this._originalBaseType, dimension: this._originalDimension },
          this._originalIdentifier
        );

        // Create assignment node for GLSL generation
        const assignmentNode = createNodeData({
          nodeType: NodeType.ASSIGNMENT,
          dependsOn: [targetVarID, newValueID],
          phiBlocks: []
        });
        const assignmentID = getOrCreateNode(dag, assignmentNode);
        recordInBasicBlock(cfg, cfg.currentBlock, assignmentID);

        // Simply update this node to be a variable node with the identifier
        // This ensures it always generates the variable name in expressions
        const variableNodeData = createNodeData({
          nodeType: NodeType.VARIABLE,
          baseType: this._originalBaseType,
          dimension: this._originalDimension,
          identifier: this._originalIdentifier
        });
        const variableID = getOrCreateNode(dag, variableNodeData);

        this.id = variableID; // Point to the variable node for expression generation
      } else {
        this.id = newValueID; // For non-varying variables, just update to new value
      }

      return this;
    }
    bridgeSwizzle(swizzlePattern, value) {
      const { dag, cfg } = this.strandsContext;
      const orig = getNodeDataFromID(dag, this.id);
      const baseType = orig?.baseType ?? BaseType.FLOAT;

      let newValueID;
      if (value?.isStrandsNode) {
        newValueID = value.id;
      } else {
        const newVal = primitiveConstructorNode(
          this.strandsContext,
          { baseType, dimension: this.dimension },
          value
        );
        newValueID = newVal.id;
      }

      // For varying variables, create swizzle assignment
      if (this._originalIdentifier) {
        const valueDim = value?.isStrandsNode
          ? value.dimension
          : (Array.isArray(value) ? value.length : 1);
        if (valueDim !== swizzlePattern.length && valueDim !== 1){
          dimensionMismatchError(
            swizzlePattern.length,
            valueDim,
            `${this._originalIdentifier}.${swizzlePattern}`
          );
        }
        // Create a variable node for the target with swizzle
        const { id: targetVarID } = variableNode(
          this.strandsContext,
          { baseType: this._originalBaseType, dimension: this._originalDimension },
          this._originalIdentifier
        );

        // Create a swizzle node for the target (myVarying.xyz)
        const swizzleNode = createNodeData({
          nodeType: NodeType.OPERATION,
          opCode: OpCode.Unary.SWIZZLE,
          baseType: this._originalBaseType,
          dimension: swizzlePattern.length, // xyz = 3, xy = 2, etc.
          swizzle: swizzlePattern,
          dependsOn: [targetVarID]
        });
        const swizzleID = getOrCreateNode(dag, swizzleNode);

        // Create assignment node: myVarying.xyz = value
        const assignmentNode = createNodeData({
          nodeType: NodeType.ASSIGNMENT,
          dependsOn: [swizzleID, newValueID],
          phiBlocks: []
        });
        const assignmentID = getOrCreateNode(dag, assignmentNode);
        recordInBasicBlock(cfg, cfg.currentBlock, assignmentID);

        // Simply update this node to be a variable node with the identifier
        // This ensures it always generates the variable name in expressions
        const variableNodeData = createNodeData({
          nodeType: NodeType.VARIABLE,
          baseType: this._originalBaseType,
          dimension: this._originalDimension,
          identifier: this._originalIdentifier
        });
        const variableID = getOrCreateNode(dag, variableNodeData);

        this.id = variableID; // Point to the variable node, not the assignment node
      } else {
        this.id = newValueID; // For non-varying variables, just update to new value
      }

      return this;
    }
    getValue() {
      if (this._originalIdentifier) {
        const { id, dimension } = variableNode(
          this.strandsContext,
          { baseType: this._originalBaseType, dimension: this._originalDimension },
          this._originalIdentifier
        );
        return createStrandsNode(id, dimension, this.strandsContext);
      }

      return this;
    }

    get(index) {
      const nodeData = getNodeDataFromID(this.strandsContext.dag, this.id);

      // Validate baseType is 'storage'
      // For struct storage buffers, return a proxy with per-field getters/setters
      if (nodeData.baseType === 'storage' && this._schema) {
        return createStructArrayElementProxy(this.strandsContext, this, index, this._schema);
      }

      // Create array access node for storage and non-storage (vector) access
      const { id, dimension } = arrayAccessNode(
        this.strandsContext,
        this,
        index);
      return createStrandsNode(id, dimension, this.strandsContext);
    }

    set(index, value) {
      // Validate baseType is 'storage' and has _originalIdentifier
      const nodeData = getNodeDataFromID(this.strandsContext.dag, this.id);
      if (nodeData.baseType !== 'storage') {
        throw new Error('set() can only be used on storage buffers');
      }
      if (!this._originalIdentifier) {
        throw new Error('set() can only be used on storage buffers with an identifier');
      }

      // If value is a plain object (struct literal), expand to per-field assignments
      // e.g. buf[idx] = { position: pos, velocity: vel }
      // becomes buf[idx].position = pos; buf[idx].velocity = vel;
      if (value !== null && typeof value === 'object' && !value.isStrandsNode && this._schema) {
        const proxy = createStructArrayElementProxy(this.strandsContext, this, index, this._schema);
        for (const [fieldName, fieldValue] of Object.entries(value)) {
          proxy[fieldName] = fieldValue;
        }
        return this;
      }

      // Create array assignment node: buffer.set(index, value) -> buffer[index] = value
      // This creates an ASSIGNMENT node and records it in the CFG basic block
      // CFG preserves sequential order, preventing reordering of assignments
      arrayAssignmentNode(this.strandsContext, this, index, value);

      // Return this for chaining
      return this;
    }
  }
  function createStrandsNode(id, dimension, strandsContext, onRebind) {
    return new Proxy(
      new StrandsNode(id, dimension, strandsContext),
      swizzleTrap(id, dimension, strandsContext, onRebind)
    );
  }

  // Need the .js extension because we also import this from a Node script.
  // Try to keep this file minimal because of that.

  // GLSL Built in functions
  // https://docs.gl/el3/abs
  const builtInGLSLFunctions = {
    //////////// Trigonometry //////////
    acos: [{ params: [GenType.FLOAT], returnType: GenType.FLOAT, isp5Function: true}],
    acosh: [{ params: [GenType.FLOAT], returnType: GenType.FLOAT, isp5Function: false}],
    asin: [{ params: [GenType.FLOAT], returnType: GenType.FLOAT, isp5Function: true}],
    asinh: [{ params: [GenType.FLOAT], returnType: GenType.FLOAT, isp5Function: false}],
    atan: [
      { params: [GenType.FLOAT], returnType: GenType.FLOAT, isp5Function: true},
      { params: [GenType.FLOAT, GenType.FLOAT], returnType: GenType.FLOAT, isp5Function: true},
    ],
    atanh: [{ params: [GenType.FLOAT], returnType: GenType.FLOAT, isp5Function: false}],
    cos: [{ params: [GenType.FLOAT], returnType: GenType.FLOAT, isp5Function: true}],
    cosh: [{ params: [GenType.FLOAT], returnType: GenType.FLOAT, isp5Function: false}],
    degrees: [{ params: [GenType.FLOAT], returnType: GenType.FLOAT, isp5Function: true}],
    radians: [{ params: [GenType.FLOAT], returnType: GenType.FLOAT, isp5Function: true}],
    sin: [{ params: [GenType.FLOAT], returnType: GenType.FLOAT , isp5Function: true}],
    sinh: [{ params: [GenType.FLOAT], returnType: GenType.FLOAT, isp5Function: false}],
    tan: [{ params: [GenType.FLOAT], returnType: GenType.FLOAT, isp5Function: true}],
    tanh: [{ params: [GenType.FLOAT], returnType: GenType.FLOAT, isp5Function: false}],

    ////////// Mathematics //////////
    abs: [
      { params: [GenType.FLOAT], returnType: GenType.FLOAT, isp5Function: true},
      { params: [GenType.FLOAT], returnType: GenType.INT, isp5Function: true}
    ],
    ceil: [{ params: [GenType.FLOAT], returnType: GenType.FLOAT, isp5Function: true}],
    clamp: [
      { params: [GenType.FLOAT, GenType.FLOAT, GenType.FLOAT], returnType: GenType.FLOAT, isp5Function: false},
      { params: [GenType.FLOAT,DataType.float1,DataType.float1], returnType: GenType.FLOAT, isp5Function: false},
      { params: [GenType.INT, GenType.INT, GenType.INT], returnType: GenType.INT, isp5Function: false},
      { params: [GenType.INT, DataType.int1, DataType.int1], returnType: GenType.INT, isp5Function: false},
    ],
    dFdx: [{ params: [GenType.FLOAT], returnType: GenType.FLOAT, isp5Function: false}],
    dFdy: [{ params: [GenType.FLOAT], returnType: GenType.FLOAT, isp5Function: false}],
    exp: [{ params: [GenType.FLOAT], returnType: GenType.FLOAT, isp5Function: true}],
    exp2: [{ params: [GenType.FLOAT], returnType: GenType.FLOAT, isp5Function: false}],
    floor: [{ params: [GenType.FLOAT], returnType: GenType.FLOAT, isp5Function: true}],
    fma: [{ params: [GenType.FLOAT, GenType.FLOAT, GenType.FLOAT], returnType: GenType.FLOAT, isp5Function: false}],
    fract: [{ params: [GenType.FLOAT], returnType: GenType.FLOAT, isp5Function: true}],
    fwidth: [{ params: [GenType.FLOAT], returnType: GenType.FLOAT, isp5Function: false}],
    inversesqrt: [{ params: [GenType.FLOAT], returnType: GenType.FLOAT, isp5Function: true}],
    // "isinf": [{}],
    // "isnan": [{}],
    log: [{ params: [GenType.FLOAT], returnType: GenType.FLOAT, isp5Function: true}],
    log2: [{ params: [GenType.FLOAT], returnType: GenType.FLOAT, isp5Function: false}],
    max: [
      { params: [GenType.FLOAT, GenType.FLOAT], returnType: GenType.FLOAT, isp5Function: true},
      { params: [GenType.FLOAT,DataType.float1], returnType: GenType.FLOAT, isp5Function: true},
      { params: [GenType.INT, GenType.INT], returnType: GenType.INT, isp5Function: true},
      { params: [GenType.INT, DataType.int1], returnType: GenType.INT, isp5Function: true},
    ],
    min: [
      { params: [GenType.FLOAT, GenType.FLOAT], returnType: GenType.FLOAT, isp5Function: true},
      { params: [GenType.FLOAT,DataType.float1], returnType: GenType.FLOAT, isp5Function: true},
      { params: [GenType.INT, GenType.INT], returnType: GenType.INT, isp5Function: true},
      { params: [GenType.INT, DataType.int1], returnType: GenType.INT, isp5Function: true},
    ],
    mix: [
      { params: [GenType.FLOAT, GenType.FLOAT, GenType.FLOAT], returnType: GenType.FLOAT, isp5Function: false},
      { params: [GenType.FLOAT, GenType.FLOAT,DataType.float1], returnType: GenType.FLOAT, isp5Function: false},
      { params: [GenType.FLOAT, GenType.FLOAT, GenType.BOOL], returnType: GenType.FLOAT, isp5Function: false},
    ],
    mod: [
      { params: [GenType.FLOAT, GenType.FLOAT], returnType: GenType.FLOAT, isp5Function: true},
      { params: [GenType.FLOAT,DataType.float1], returnType: GenType.FLOAT, isp5Function: true},
    ],
    // "modf": [{}],
    pow: [{ params: [GenType.FLOAT, GenType.FLOAT], returnType: GenType.FLOAT, isp5Function: true}],
    round: [{ params: [GenType.FLOAT], returnType: GenType.FLOAT, isp5Function: true}],
    roundEven: [{ params: [GenType.FLOAT], returnType: GenType.FLOAT, isp5Function: false}],
    sign: [
      { params: [GenType.FLOAT], returnType: GenType.FLOAT, isp5Function: false},
      { params: [GenType.INT], returnType: GenType.INT, isp5Function: false},
    ],
    smoothstep: [
      { params: [GenType.FLOAT, GenType.FLOAT, GenType.FLOAT], returnType: GenType.FLOAT, isp5Function: false},
      { params: [ DataType.float1,DataType.float1, GenType.FLOAT], returnType: GenType.FLOAT, isp5Function: false},
    ],
    sqrt: [{ params: [GenType.FLOAT], returnType: GenType.FLOAT, isp5Function: true}],
    step: [{ params: [GenType.FLOAT, GenType.FLOAT], returnType: GenType.FLOAT, isp5Function: false}],
    trunc: [{ params: [GenType.FLOAT], returnType: GenType.FLOAT, isp5Function: false}],

    ////////// Vector //////////
    cross: [{ params: [DataType.float3, DataType.float3], returnType: DataType.float3, isp5Function: true}],
    distance: [{ params: [GenType.FLOAT, GenType.FLOAT], returnType:DataType.float1, isp5Function: true}],
    dot: [{ params: [GenType.FLOAT, GenType.FLOAT], returnType:DataType.float1, isp5Function: true}],
    equal: [
      { params: [GenType.FLOAT, GenType.FLOAT], returnType: GenType.BOOL, isp5Function: false},
      { params: [GenType.INT, GenType.INT], returnType: GenType.BOOL, isp5Function: false},
      { params: [GenType.BOOL, GenType.BOOL], returnType: GenType.BOOL, isp5Function: false},
    ],
    faceforward: [{ params: [GenType.FLOAT, GenType.FLOAT, GenType.FLOAT], returnType: GenType.FLOAT, isp5Function: false}],
    length: [{ params: [GenType.FLOAT], returnType:DataType.float1, isp5Function: false}],
    normalize: [{ params: [GenType.FLOAT], returnType: GenType.FLOAT, isp5Function: true}],
    notEqual: [
      { params: [GenType.FLOAT, GenType.FLOAT], returnType: GenType.BOOL, isp5Function: false},
      { params: [GenType.INT, GenType.INT], returnType: GenType.BOOL, isp5Function: false},
      { params: [GenType.BOOL, GenType.BOOL], returnType: GenType.BOOL, isp5Function: false},
    ],
    reflect: [{ params: [GenType.FLOAT, GenType.FLOAT], returnType: GenType.FLOAT, isp5Function: false}],
    refract: [{ params: [GenType.FLOAT, GenType.FLOAT,DataType.float1], returnType: GenType.FLOAT, isp5Function: false}],
  };

  const strandsBuiltinFunctions = {
    ...builtInGLSLFunctions,
  };

  //////////////////////////////////////////////
  // Builders for node graphs
  //////////////////////////////////////////////
  function scalarLiteralNode(strandsContext, typeInfo, value) {
    const { cfg, dag } = strandsContext;
    let { dimension, baseType } = typeInfo;
    if (dimension !== 1) {
      internalError('Created a scalar literal node with dimension > 1.');
    }
    const nodeData = createNodeData({
      nodeType: NodeType.LITERAL,
      dimension,
      baseType,
      value
    });
    const id = getOrCreateNode(dag, nodeData);
    recordInBasicBlock(cfg, cfg.currentBlock, id);
    return { id, dimension };
  }

  function variableNode(strandsContext, typeInfo, identifier) {
    const { cfg, dag } = strandsContext;
    const { dimension, baseType } = typeInfo;
    const nodeData = createNodeData({
      nodeType: NodeType.VARIABLE,
      dimension,
      baseType,
      identifier
    });
    const id = getOrCreateNode(dag, nodeData);
    recordInBasicBlock(cfg, cfg.currentBlock, id);
    return { id, dimension };
  }

  function mapPrimitiveDepsToIDs(strandsContext, typeInfo, dependsOn) {
    const inputs = Array.isArray(dependsOn) ? dependsOn : [dependsOn];
    const mappedDependencies = [];
    let { dimension, baseType } = typeInfo;

    const dag = strandsContext.dag;
    let calculatedDimensions = 0;
    let originalNodeID = null;
    for (const dep of inputs.flat(Infinity)) {
      if (dep && dep.isStrandsNode) {
        const node = getNodeDataFromID(dag, dep.id);
        originalNodeID = dep.id;
        baseType = node.baseType;

        if (node.opCode === OpCode.Nary.CONSTRUCTOR) {
          for (const inner of node.dependsOn) {
            mappedDependencies.push(inner);
          }
        } else {
          mappedDependencies.push(dep.id);
        }

        calculatedDimensions += node.dimension;
        continue;
      }
      else if (typeof dep === 'number') {
        const { id, dimension } = scalarLiteralNode(strandsContext, { dimension: 1, baseType }, dep);
        mappedDependencies.push(id);
        calculatedDimensions += dimension;
        continue;
      }
      else if (typeof dep === 'boolean') {
        // Handle boolean literals - convert to bool type
        const { id, dimension } = scalarLiteralNode(strandsContext, { dimension: 1, baseType: BaseType.BOOL }, dep);
        mappedDependencies.push(id);
        calculatedDimensions += dimension;
        // Update baseType to BOOL if it was inferred
        if (baseType !== BaseType.BOOL) {
          baseType = BaseType.BOOL;
        }
        continue;
      }
      else {
        userError('type error', `You've tried to construct a scalar or vector type with a non-numeric value: ${dep}`);
      }
    }
    if (dimension === null) {
      dimension = calculatedDimensions;
    } else if (dimension > calculatedDimensions && calculatedDimensions === 1) {
      calculatedDimensions = dimension;
    } else if(calculatedDimensions !== 1 && calculatedDimensions !== dimension) {
      userError('type error', `You've tried to construct a ${baseType + dimension} with ${calculatedDimensions} components`);
    }
    const inferredTypeInfo = {
      dimension,
      baseType,
      priority: BasePriority[baseType],
    };
    return { originalNodeID, mappedDependencies, inferredTypeInfo };
  }

  function constructTypeFromIDs(strandsContext, typeInfo, strandsNodesArray) {
    const nodeData = createNodeData({
      nodeType: NodeType.OPERATION,
      opCode: OpCode.Nary.CONSTRUCTOR,
      dimension: typeInfo.dimension,
      baseType: typeInfo.baseType,
      dependsOn: strandsNodesArray
    });
    const id = getOrCreateNode(strandsContext.dag, nodeData);
    return id;
  }

  function primitiveConstructorNode(strandsContext, typeInfo, dependsOn) {
    const cfg = strandsContext.cfg;
    dependsOn = (Array.isArray(dependsOn) ? dependsOn : [dependsOn])
      .flat(Infinity)
      .map(a => {
        if (
          a.isStrandsNode &&
          a.typeInfo().baseType === BaseType.INT &&
          // TODO: handle ivec inputs instead of just int scalars
          a.typeInfo().dimension === 1
        ) {
          return castToFloat(strandsContext, a);
        } else {
          return a;
        }
      });
    const { mappedDependencies, inferredTypeInfo } = mapPrimitiveDepsToIDs(strandsContext, typeInfo, dependsOn);

    const finalType = {
      // We might have inferred a non numeric type. Currently this is
      // just used for booleans. Maybe this needs to be something more robust
      // if we ever want to support inference of e.g. int vectors?
      baseType: inferredTypeInfo.baseType === BaseType.BOOL
        ? BaseType.BOOL
        : typeInfo.baseType,
      dimension: inferredTypeInfo.dimension
    };

    const id = constructTypeFromIDs(strandsContext, finalType, mappedDependencies);
    if (typeInfo.baseType !== BaseType.DEFER) {
      recordInBasicBlock(cfg, cfg.currentBlock, id);
    }

    return { id, dimension: finalType.dimension, components: mappedDependencies };
  }

  function castToFloat(strandsContext, dep) {
    const { id, dimension } = functionCallNode(
      strandsContext,
      strandsContext.backend.getTypeName('float', dep.typeInfo().dimension),
      [dep],
      {
        overloads: [{
          params: [dep.typeInfo()],
          returnType: {
            ...dep.typeInfo(),
            baseType: BaseType.FLOAT,
          },
        }],
      }
    );
    return createStrandsNode(id, dimension, strandsContext);
  }

  function functionCallNode(
    strandsContext,
    functionName,
    rawUserArgs,
    { overloads: rawOverloads } = {},
  ) {
    const { cfg, dag } = strandsContext;
    const overloads = rawOverloads || strandsBuiltinFunctions[functionName];

    const preprocessedArgs = rawUserArgs.map((rawUserArg) => mapPrimitiveDepsToIDs(strandsContext, DataType.defer, rawUserArg));
    const matchingArgsCounts = overloads.filter(overload => overload.params.length === preprocessedArgs.length);
    if (matchingArgsCounts.length === 0) {
      const argsLengthSet = new Set();
      const argsLengthArr = [];
      overloads.forEach((overload) => argsLengthSet.add(overload.params.length));
      argsLengthSet.forEach((len) => argsLengthArr.push(`${len}`));
      const argsLengthStr = argsLengthArr.join(', or ');
      userError("parameter validation error",`Function '${functionName}' has ${overloads.length} variants which expect ${argsLengthStr} arguments, but ${preprocessedArgs.length} arguments were provided.`);
    }

    const isGeneric = (T) => T.dimension === null;
    let bestOverload = null;
    let bestScore = 0;
    let inferredReturnType = null;
    let inferredDimension = null;

    for (const overload of matchingArgsCounts) {
      let isValid = true;
      let similarity = 0;

      for (let i = 0; i < preprocessedArgs.length; i++) {
        const preArg = preprocessedArgs[i];
        const argType = preArg.inferredTypeInfo;
        const expectedType = overload.params[i];
        let dimension = expectedType.dimension;

        if (isGeneric(expectedType)) {
          if (inferredDimension === null || inferredDimension === 1) {
            inferredDimension = argType.dimension;
          }

          if (inferredDimension !== argType.dimension &&
            !(argType.dimension === 1 && inferredDimension >= 1)
            ) {
            isValid = false;
          }
          dimension = inferredDimension;
        }
        else {
          if (argType.dimension > dimension) {
            isValid = false;
          }
        }

        if (argType.baseType === expectedType.baseType) {
          similarity += 2;
        }
        else if(expectedType.priority > argType.priority) {
          similarity += 1;
        }

      }

      if (isValid && (!bestOverload || similarity > bestScore)) {
        bestOverload = overload;
        bestScore = similarity;
        inferredReturnType =  {...overload.returnType };
        if (isGeneric(inferredReturnType)) {
          inferredReturnType.dimension = inferredDimension;
        }
      }
    }

    if (bestOverload === null) {
      userError('parameter validation', `No matching overload for ${functionName} was found!`);
    }

    let dependsOn = [];
    for (let i = 0; i < bestOverload.params.length; i++) {
      const arg = preprocessedArgs[i];
      const paramType = { ...bestOverload.params[i] };
      if (isGeneric(paramType)) {
        paramType.dimension = inferredDimension;
      }
      if (arg.originalNodeID && typeEquals(arg.inferredTypeInfo, paramType)) {
        dependsOn.push(arg.originalNodeID);
      }
      else {
        const castedArgID = constructTypeFromIDs(strandsContext, paramType, arg.mappedDependencies);
        recordInBasicBlock(cfg, cfg.currentBlock, castedArgID);
        dependsOn.push(castedArgID);
      }
    }

    const nodeData = createNodeData({
      nodeType: NodeType.OPERATION,
      opCode: OpCode.Nary.FUNCTION_CALL,
      identifier: functionName,
      dependsOn,
      baseType: inferredReturnType.baseType,
      dimension: inferredReturnType.dimension
    });
    const id = getOrCreateNode(dag, nodeData);
    recordInBasicBlock(cfg, cfg.currentBlock, id);
    return { id, dimension: inferredReturnType.dimension  };
  }

  function swizzleNode(strandsContext, parentNode, swizzle) {
    const { dag, cfg } = strandsContext;
    const baseType = dag.baseTypes[parentNode.id];
    const nodeData = createNodeData({
      nodeType: NodeType.OPERATION,
      baseType,
      dimension: swizzle.length,
      opCode: OpCode.Unary.SWIZZLE,
      dependsOn: [parentNode.id],
      swizzle,
    });
    const id = getOrCreateNode(dag, nodeData);
    recordInBasicBlock(cfg, cfg.currentBlock, id);
    return { id, dimension: swizzle.length };
  }

  function swizzleTrap(id, dimension, strandsContext, onRebind) {
      const swizzleSets = [
        ['x', 'y', 'z', 'w'],
        ['r', 'g', 'b', 'a'],
        ['s', 't', 'p', 'q']
      ].map(s => s.slice(0, dimension));
      const trap = {
        get(target, property, receiver) {
          if (property in target) {
            return Reflect.get(...arguments);
          } else {
            for (const set of swizzleSets) {
              if ([...property.toString()].every(char => set.includes(char))) {
                const swizzle = [...property].map(char => {
                  const index = set.indexOf(char);
                  return swizzleSets[0][index];
                }).join('');
                const node = swizzleNode(strandsContext, target, swizzle);
                return createStrandsNode(node.id, node.dimension, strandsContext);
              }
            }
          }
      },
    set(target, property, value, receiver) {
      for (const swizzleSet of swizzleSets) {
        const chars = [...property];
        const valid =
          chars.every(c => swizzleSet.includes(c)) &&
          new Set(chars).size === chars.length &&
          target.dimension >= chars.length;
        if (!valid) continue;

        const dim = target.dimension;

        // lanes are the underlying values of the target vector
        //  e.g. lane 0 holds the value aliased by 'x', 'r', and 's'
        // the lanes array is in the 'correct' order
        const lanes = new Array(dim);
        for (let i = 0; i < dim; i++) {
          const { id, dimension } = swizzleNode(strandsContext, target, 'xyzw'[i]);
          lanes[i] = createStrandsNode(id, dimension, strandsContext);
        }

        // The scalars array contains the individual components of the users values.
        // This may not be the most efficient way, as we swizzle each component individually,
        // so that .xyz becomes .x, .y, .z
        let scalars = [];
        if (value?.isStrandsNode) {
          if (value.dimension === 1) {
            scalars = Array(chars.length).fill(value);
          } else if (value.dimension === chars.length) {
            for (let k = 0; k < chars.length; k++) {
              const { id, dimension } = swizzleNode(strandsContext, value, 'xyzw'[k]);
              scalars.push(createStrandsNode(id, dimension, strandsContext));
            }
          } else {
            dimensionMismatchError(
              chars.length,
              value.dimension,
              `${target._originalIdentifier || 'value'}.${property}`
            );
          }
        } else if (Array.isArray(value)) {
          const flat = value.flat(Infinity);
          if (flat.length === 1) {
            scalars = Array(chars.length).fill(flat[0]);
          } else if (flat.length === chars.length) {
            scalars = flat;
          } else {
            userError('type error', `Swizzle assignment: RHS length ${flat.length} does not match ${chars.length}.`);
          }
        } else if (typeof value === 'number') {
          scalars = Array(chars.length).fill(value);
        } else {
          userError('type error', `Unsupported RHS for swizzle assignment: ${value}`);
        }

        // The canonical index refers to the actual value's position in the vector lanes
        // i.e. we are finding (3,2,1) from .zyx
        // We set the correct value in the lanes array
        for (let j = 0; j < chars.length; j++) {
          const canonicalIndex = swizzleSet.indexOf(chars[j]);
          lanes[canonicalIndex] = scalars[j];
        }

        const orig = getNodeDataFromID(strandsContext.dag, target.id);
        const baseType = orig?.baseType ?? BaseType.FLOAT;
        const { id: newID } = primitiveConstructorNode(
          strandsContext,
          { baseType, dimension: dim },
          lanes
        );

        target.id = newID;

        // If we swizzle assign on a struct component i.e.
        //   inputs.position.rg = [1, 2]
        // The onRebind callback will update the structs components so that it refers to the new values,
        // and make a new ID for the struct with these new values
        if (typeof onRebind === 'function') {
          onRebind(newID);
        }
        return true;
      }
      return Reflect.set(...arguments);
    }
    };
    return trap;
  }

  function arrayAccessNode(strandsContext, bufferNode, indexNode, accessMode) {
    const { dag, cfg } = strandsContext;

    // Ensure index is a StrandsNode
    let index;
    if (indexNode instanceof StrandsNode) {
      index = indexNode;
    } else {
      const { id, dimension } = primitiveConstructorNode(
        strandsContext,
        { baseType: BaseType.INT, dimension: 1 },
        indexNode
      );
      index = createStrandsNode(id, dimension, strandsContext);
    }

    // Array access returns a single float
    const nodeData = createNodeData({
      nodeType: NodeType.OPERATION,
      opCode: OpCode.Binary.ARRAY_ACCESS,
      dependsOn: [bufferNode.id, index.id],
      dimension: 1,
      baseType: BaseType.FLOAT});

    const id = getOrCreateNode(dag, nodeData);
    recordInBasicBlock(cfg, cfg.currentBlock, id);

    return { id, dimension: 1 };
  }

  function createStructArrayElementProxy(strandsContext, bufferNode, indexNode, schema) {
    const { dag, cfg } = strandsContext;

    // Ensure index is a StrandsNode
    let index;
    if (indexNode instanceof StrandsNode) {
      index = indexNode;
    } else {
      const { id, dimension } = primitiveConstructorNode(
        strandsContext,
        { baseType: BaseType.INT, dimension: 1 },
        indexNode
      );
      index = createStrandsNode(id, dimension, strandsContext);
    }

    // Create a plain object with getters/setters for each struct field.
    // When read, a field creates an ARRAY_ACCESS IR node with the field name encoded
    // in the identifier slot. When written, an ASSIGNMENT IR node is recorded in the CFG.
    const proxy = {};

    for (const field of schema.fields) {
      Object.defineProperty(proxy, field.name, {
        get() {
          // Encode field name in identifier so WGSL backend can emit buf[idx].field
          const nodeData = createNodeData({
            nodeType: NodeType.OPERATION,
            opCode: OpCode.Binary.ARRAY_ACCESS,
            dependsOn: [bufferNode.id, index.id],
            dimension: field.dim,
            baseType: BaseType.FLOAT,
            identifier: field.name,
          });
          const id = getOrCreateNode(dag, nodeData);
          recordInBasicBlock(cfg, cfg.currentBlock, id);
          // When a swizzle assignment fires (e.g. buf[i].vel.y *= -1), onRebind
          // receives the new vector ID and writes it back to the buffer field,
          // equivalent to buf[i].vel = newVec.
          const onRebind = (newFieldID) => {
            const accessData = createNodeData({
              nodeType: NodeType.OPERATION,
              opCode: OpCode.Binary.ARRAY_ACCESS,
              dependsOn: [bufferNode.id, index.id],
              dimension: field.dim,
              baseType: BaseType.FLOAT,
              identifier: field.name,
            });
            const accessID = getOrCreateNode(dag, accessData);
            const assignData = createNodeData({
              nodeType: NodeType.ASSIGNMENT,
              dependsOn: [accessID, newFieldID],
              phiBlocks: [],
            });
            const assignID = getOrCreateNode(dag, assignData);
            recordInBasicBlock(cfg, cfg.currentBlock, assignID);
          };
          return createStrandsNode(id, field.dim, strandsContext, onRebind);
        },
        set(val) {
          // Create access node as assignment target (field name in identifier)
          const accessData = createNodeData({
            nodeType: NodeType.OPERATION,
            opCode: OpCode.Binary.ARRAY_ACCESS,
            dependsOn: [bufferNode.id, index.id],
            dimension: field.dim,
            baseType: BaseType.FLOAT,
            identifier: field.name,
          });
          const accessID = getOrCreateNode(dag, accessData);

          let valueID;
          if (val?.isStrandsNode) {
            valueID = val.id;
          } else {
            const { id } = primitiveConstructorNode(
              strandsContext,
              { baseType: BaseType.FLOAT, dimension: field.dim },
              val
            );
            valueID = id;
          }

          const assignData = createNodeData({
            nodeType: NodeType.ASSIGNMENT,
            dependsOn: [accessID, valueID],
            phiBlocks: [],
          });
          const assignID = getOrCreateNode(dag, assignData);
          recordInBasicBlock(cfg, cfg.currentBlock, assignID);
        },
        configurable: true,
      });
    }

    return proxy;
  }

  function arrayAssignmentNode(strandsContext, bufferNode, indexNode, valueNode) {
    const { dag, cfg } = strandsContext;

    // Ensure index is a StrandsNode
    let index;
    if (indexNode instanceof StrandsNode) {
      index = indexNode;
    } else {
      const { id, dimension } = primitiveConstructorNode(
        strandsContext,
        { baseType: BaseType.INT, dimension: 1 },
        indexNode
      );
      index = createStrandsNode(id, dimension, strandsContext);
    }

    // Ensure value is a StrandsNode
    let value;
    if (valueNode instanceof StrandsNode) {
      value = valueNode;
    } else {
      const { id, dimension } = primitiveConstructorNode(
        strandsContext,
        { baseType: BaseType.FLOAT, dimension: 1 },
        valueNode
      );
      value = createStrandsNode(id, dimension, strandsContext);
    }

    // Create array access node as the assignment target
    const arrayAccessData = createNodeData({
      nodeType: NodeType.OPERATION,
      opCode: OpCode.Binary.ARRAY_ACCESS,
      dependsOn: [bufferNode.id, index.id],
      dimension: 1,
      baseType: BaseType.FLOAT
    });
    const arrayAccessID = getOrCreateNode(dag, arrayAccessData);

    // Create assignment node: buffer[index] = value
    const assignmentData = createNodeData({
      nodeType: NodeType.ASSIGNMENT,
      dependsOn: [arrayAccessID, value.id],
      phiBlocks: []
    });
    const assignmentID = getOrCreateNode(dag, assignmentData);

    // CRITICAL: Record in CFG to preserve sequential ordering
    recordInBasicBlock(cfg, cfg.currentBlock, assignmentID);

    return { id: assignmentID };
  }

  function shouldCreateTemp(dag, nodeID) {
    const nodeType = dag.nodeTypes[nodeID];
    if (nodeType !== NodeType.OPERATION) return false;
    if (dag.baseTypes[nodeID] === BaseType.SAMPLER2D) return false;
    const uses = dag.usedBy[nodeID] || [];
    return uses.length > 1;
  }
  const TypeNames = {
    'float1': 'f32',
    'float2': 'vec2<f32>',
    'float3': 'vec3<f32>',
    'float4': 'vec4<f32>',
    'int1': 'i32',
    'int2': 'vec2<i32>',
    'int3': 'vec3<i32>',
    'int4': 'vec4<i32>',
    'bool1': 'bool',
    'bool2': 'vec2<bool>',
    'bool3': 'vec3<bool>',
    'bool4': 'vec4<bool>',
    'mat2': 'mat2x2<f32>',
    'mat3': 'mat3x3<f32>',
    'mat4': 'mat4x4<f32>',
  };
  const cfgHandlers = {
    [BlockType.DEFAULT]: (blockID, strandsContext, generationContext) => {
      const { dag, cfg } = strandsContext;
      const instructions = cfg.blockInstructions[blockID] || [];
      for (const nodeID of instructions) {
        const nodeType = dag.nodeTypes[nodeID];
        if (shouldCreateTemp(dag, nodeID)) {
          const declaration = wgslBackend.generateDeclaration(generationContext, dag, nodeID);
          generationContext.write(declaration);
        }
        if (nodeType === NodeType.STATEMENT) {
          wgslBackend.generateStatement(generationContext, dag, nodeID);
        }
        if (nodeType === NodeType.ASSIGNMENT) {
          wgslBackend.generateAssignment(generationContext, dag, nodeID);
          generationContext.visitedNodes.add(nodeID);
        }
      }
    },
    [BlockType.BRANCH](blockID, strandsContext, generationContext) {
      const { dag, cfg } = strandsContext;
      // Find all phi nodes in this branch block and declare them
      const blockInstructions = cfg.blockInstructions[blockID] || [];
      for (const nodeID of blockInstructions) {
        const node = getNodeDataFromID(dag, nodeID);
        if (node.nodeType === NodeType.PHI) {
          // Check if the phi node's first dependency already has a temp name
          const dependsOn = node.dependsOn || [];
          if (dependsOn.length > 0) {
            const firstDependency = dependsOn[0];
            const existingTempName = generationContext.tempNames[firstDependency];
            if (existingTempName) {
              // Reuse the existing temp name instead of creating a new one
              generationContext.tempNames[nodeID] = existingTempName;
              continue; // Skip declaration, just alias to existing variable
            }
          }

          // Otherwise, create a new temp variable for the phi node
          const tmp = `T${generationContext.nextTempID++}`;
          generationContext.tempNames[nodeID] = tmp;
          const T = extractNodeTypeInfo(dag, nodeID);
          const typeName = wgslBackend.getTypeName(T.baseType, T.dimension);
          // Initialize with default value - WGSL requires initialization
          let defaultValue;
          if (T.dimension === 1) {
            defaultValue = this.defaultScalarValue(T.baseType);
          } else {
            // For vector types, use constructor with repeated scalar values
            const scalarDefault = this.defaultScalarValue(T.baseType);
            const components = Array(T.dimension).fill(scalarDefault).join(', ');
            defaultValue = `${typeName}(${components})`;
          }
          generationContext.write(`var ${tmp}: ${typeName} = ${defaultValue};`);
        }
      }
      this[BlockType.DEFAULT](blockID, strandsContext, generationContext);
    },
    defaultScalarValue(baseType) {
      if (baseType === BaseType.FLOAT) {
        return '0.0';
      } else if (baseType === BaseType.BOOL) {
        return 'false';
      } else {
        return '0';
      }
    },
    [BlockType.IF_COND](blockID, strandsContext, generationContext) {
      const { dag, cfg } = strandsContext;
      const conditionID = cfg.blockConditions[blockID];
      const condExpr = wgslBackend.generateExpression(generationContext, dag, conditionID);
      generationContext.write(`if (${condExpr})`);
      this[BlockType.DEFAULT](blockID, strandsContext, generationContext);
    },
    [BlockType.ELSE_COND](blockID, strandsContext, generationContext) {
      generationContext.write(`else`);
      this[BlockType.DEFAULT](blockID, strandsContext, generationContext);
    },
    [BlockType.IF_BODY](blockID, strandsContext, generationContext) {
      this[BlockType.DEFAULT](blockID, strandsContext, generationContext);
      this.assignPhiNodeValues(blockID, strandsContext, generationContext);
    },
    [BlockType.SCOPE_START](blockID, strandsContext, generationContext) {
      generationContext.write(`{`);
      generationContext.indent++;
    },
    [BlockType.SCOPE_END](blockID, strandsContext, generationContext) {
      generationContext.indent--;
      generationContext.write(`}`);
    },
    [BlockType.MERGE](blockID, strandsContext, generationContext) {
      this[BlockType.DEFAULT](blockID, strandsContext, generationContext);
    },
    [BlockType.FUNCTION](blockID, strandsContext, generationContext) {
      this[BlockType.DEFAULT](blockID, strandsContext, generationContext);
    },
    [BlockType.FOR](blockID, strandsContext, generationContext) {
      const { dag, cfg } = strandsContext;
      const instructions = cfg.blockInstructions[blockID] || [];

      generationContext.write(`for (`);

      // Set flag to suppress semicolon on the last statement
      const originalSuppressSemicolon = generationContext.suppressSemicolon;

      for (let i = 0; i < instructions.length; i++) {
        const nodeID = instructions[i];
        const node = getNodeDataFromID(dag, nodeID);
        const isLast = i === instructions.length - 1;

        // Suppress semicolon on the last statement
        generationContext.suppressSemicolon = isLast;

        if (shouldCreateTemp(dag, nodeID)) {
          const declaration = wgslBackend.generateDeclaration(generationContext, dag, nodeID);
          generationContext.write(declaration);
        }
        if (node.nodeType === NodeType.STATEMENT) {
          wgslBackend.generateStatement(generationContext, dag, nodeID);
        }
        if (node.nodeType === NodeType.ASSIGNMENT) {
          wgslBackend.generateAssignment(generationContext, dag, nodeID);
          generationContext.visitedNodes.add(nodeID);
        }
      }

      // Restore original flag
      generationContext.suppressSemicolon = originalSuppressSemicolon;

      generationContext.write(`)`);
    },
    assignPhiNodeValues(blockID, strandsContext, generationContext) {
      const { dag, cfg } = strandsContext;
      // Find all phi nodes that this block feeds into
      const successors = cfg.outgoingEdges[blockID] || [];
      for (const successorBlockID of successors) {
        const instructions = cfg.blockInstructions[successorBlockID] || [];
        for (const nodeID of instructions) {
          const node = getNodeDataFromID(dag, nodeID);
          if (node.nodeType === NodeType.PHI) {
            // Find which input of this phi node corresponds to our block
            const branchIndex = node.phiBlocks?.indexOf(blockID);
            if (branchIndex !== -1 && branchIndex < node.dependsOn.length) {
              const sourceNodeID = node.dependsOn[branchIndex];
              const tempName = generationContext.tempNames[nodeID];
              if (tempName && sourceNodeID !== null) {
                const sourceExpr = wgslBackend.generateExpression(generationContext, dag, sourceNodeID);
                generationContext.write(`${tempName} = ${sourceExpr};`);
              }
            }
          }
        }
      }
    },
  };
  const wgslBackend = {
    hookEntry(hookType) {
      const params = hookType.parameters.map((param) => {
        // For struct types, use a raw prefix since we'll create a mutable copy
        const paramName = param.type.properties ? `_p5_strands_raw_${param.name}` : `${HOOK_PARAM_PREFIX}${param.name}`;
        return `${paramName}: ${param.type.typeName}`;
      }).join(', ');

      const firstLine = `(${params}) {`;

      // Generate mutable copies for struct parameters
      const mutableCopies = hookType.parameters
        .filter(param => param.type.properties) // Only struct types
        .map(param => `  var ${HOOK_PARAM_PREFIX}${param.name} = _p5_strands_raw_${param.name};`)
        .join('\n');

      return mutableCopies ? firstLine + '\n' + mutableCopies : firstLine;
    },
    addTextureBindingsToDeclarations(strandsContext) {
      // Add texture and sampler bindings for sampler2D uniforms to both vertex and fragment declarations
      if (!strandsContext.renderer || !strandsContext.baseShader) return;

      let bindingIndex = strandsContext.renderer.getNextBindingIndex({
        vert: strandsContext.baseShader._vertSrc,
        frag: strandsContext.baseShader._fragSrc,
        compute: strandsContext.baseShader._computeSrc,
      });

      for (const {name, typeInfo} of strandsContext.uniforms) {
        if (typeInfo.baseType === 'sampler2D') {
          const textureBinding = `@group(0) @binding(${bindingIndex}) var ${name}: texture_2d<f32>;`;
          const samplerBinding = `@group(0) @binding(${bindingIndex + 1}) var ${name}_sampler: sampler;`;

          strandsContext.vertexDeclarations.add(textureBinding);
          strandsContext.vertexDeclarations.add(samplerBinding);
          strandsContext.fragmentDeclarations.add(textureBinding);
          strandsContext.fragmentDeclarations.add(samplerBinding);

          bindingIndex += 2;
        }
      }
    },
    addStorageBufferBindingsToDeclarations(strandsContext) {
      if (!strandsContext.renderer || !strandsContext.baseShader) return;

      const isComputeShader = strandsContext.baseShader.shaderType === 'compute';
      let bindingIndex = strandsContext.renderer.getNextBindingIndex({
        vert: strandsContext.baseShader._vertSrc,
        frag: strandsContext.baseShader._fragSrc,
        compute: strandsContext.baseShader._computeSrc,
      });

      for (const {name, typeInfo} of strandsContext.uniforms) {
        if (typeInfo.baseType === 'storage') {
          const accessMode = isComputeShader ? 'read_write' : 'read';
          let declaration;
          if (typeInfo.schema) {
            const structTypeName = `${name}Element`;
            declaration = `struct ${structTypeName} ${typeInfo.schema.structBody}\n@group(0) @binding(${bindingIndex}) var<storage, ${accessMode}> ${name}: array<${structTypeName}>;`;
          } else {
            declaration = `@group(0) @binding(${bindingIndex}) var<storage, ${accessMode}> ${name}: array<f32>;`;
          }

          if (isComputeShader) {
            strandsContext.computeDeclarations.add(declaration);
          } else {
            strandsContext.vertexDeclarations.add(declaration);
            strandsContext.fragmentDeclarations.add(declaration);
          }

          bindingIndex += 1;
        }
      }
    },
    getTypeName(baseType, dimension) {
      const primitiveTypeName = TypeNames[baseType + dimension];
      if (!primitiveTypeName) {
        return baseType;
      }
      return primitiveTypeName;
    },
    getNoiseShaderSnippet() {
      return noiseWGSL;
    },
    getRandomFragmentShaderSnippet() {
      return randomWGSL;
    },
    getRandomVertexShaderSnippet() {
      return randomVertWGSL;
    },
    getRandomComputeShaderSnippet() {
      return randomComputeWGSL;
    },

    generateHookUniformKey(name, typeInfo) {
      // For sampler2D types, we don't add them to the uniform struct,
      // but we still need them in the shader's hooks object so that
      // they can be set by users.
      if (typeInfo.baseType === 'sampler2D') {
        return `${name}: sampler2D`; // Signal that this should not be added to uniform struct
      }
      // For storage buffers, we don't add them to the uniform struct
      // Instead, they become separate storage buffer bindings
      if (typeInfo.baseType === 'storage') {
        return null; // Signal that this should not be added to uniform struct
      }
      return `${name}: ${this.getTypeName(typeInfo.baseType, typeInfo.dimension)}`;
    },
    generateVaryingVariable(varName, typeInfo) {
      const typeName = this.getTypeName(typeInfo.baseType, typeInfo.dimension);
      return `${varName}: ${typeName}`;
    },
    generateLocalDeclaration(varName, typeInfo) {
      const typeName = this.getTypeName(typeInfo.baseType, typeInfo.dimension);
      return `var<private> ${varName}: ${typeName};`;
    },
    generateStatement(generationContext, dag, nodeID) {
      const node = getNodeDataFromID(dag, nodeID);
      // Generate the expression followed by semicolon (unless suppressed)
      const semicolon = generationContext.suppressSemicolon ? '' : ';';
      if (node.statementType === StatementType.DISCARD) {
        generationContext.write(`discard${semicolon}`);
      } else if (node.statementType === StatementType.BREAK) {
        generationContext.write(`break${semicolon}`);
      } else if (node.statementType === StatementType.EXPRESSION) {
        const exprNodeID = node.dependsOn[0];
        const expr = this.generateExpression(generationContext, dag, exprNodeID);
        generationContext.write(`${expr}${semicolon}`);
      } else if (node.statementType === StatementType.EMPTY) {
        // Generate just a semicolon (unless suppressed)
        generationContext.write(semicolon);
      } else if (node.statementType === StatementType.EARLY_RETURN) {
        if (node.dependsOn && node.dependsOn.length > 0) {
          const exprNodeID = node.dependsOn[0];
          const expr = this.generateExpression(generationContext, dag, exprNodeID);
          generationContext.write(`return ${expr}${semicolon}`);
        } else {
          generationContext.write(`return${semicolon}`);
        }
      }
    },
    generateAssignment(generationContext, dag, nodeID) {
      const node = getNodeDataFromID(dag, nodeID);
      // dependsOn[0] = targetNodeID, dependsOn[1] = sourceNodeID
      const targetNodeID = node.dependsOn[0];
      const sourceNodeID = node.dependsOn[1];

      const targetNode = getNodeDataFromID(dag, targetNodeID);
      const semicolon = generationContext.suppressSemicolon ? '' : ';';

      // Check if target is an array access (storage buffer assignment)
      if (targetNode.opCode === OpCode.Binary.ARRAY_ACCESS) {
        const [bufferID, indexID] = targetNode.dependsOn;
        const bufferExpr = this.generateExpression(generationContext, dag, bufferID);
        const indexExpr = this.generateExpression(generationContext, dag, indexID);
        const sourceExpr = this.generateExpression(generationContext, dag, sourceNodeID);
        const fieldSuffix = targetNode.identifier ? `.${targetNode.identifier}` : '';
        generationContext.write(`${bufferExpr}[i32(${indexExpr})]${fieldSuffix} = ${sourceExpr}${semicolon}`);
        return;
      }

      // Check if target is a swizzle assignment
      if (targetNode.opCode === OpCode.Unary.SWIZZLE) {
        const parentID = targetNode.dependsOn[0];
        const parentNode = getNodeDataFromID(dag, parentID);
        const parentExpr = this.generateExpression(generationContext, dag, parentID);
        const swizzle = targetNode.swizzle;
        const parentDimension = parentNode.dimension;
        const sourceExpr = this.generateExpression(generationContext, dag, sourceNodeID);

        // Create an array for each element of the target variable
        const componentMap = [];
        for (let i = 0; i < parentDimension; i++) {
          componentMap[i] = { target: 'self', index: i };
        }

        // Map swizzle characters to component indices
        const getComponentIndex = (char) => {
          if ('xyzw'.includes(char)) return 'xyzw'.indexOf(char);
          if ('rgba'.includes(char)) return 'rgba'.indexOf(char);
          return -1;
        };

        // Update the component map based on the swizzle assignment
        for (let i = 0; i < swizzle.length; i++) {
          const targetComponentIndex = getComponentIndex(swizzle[i]);
          if (targetComponentIndex >= 0 && targetComponentIndex < parentDimension) {
            componentMap[targetComponentIndex] = { target: 'rhs', index: i };
          }
        }

        // Generate the reconstruction expression
        const vectorTypeName = this.getTypeName(parentNode.baseType, parentDimension);
        const components = componentMap.map(({ target, index }) => {
          return `${target === 'self' ? parentExpr : sourceExpr}.${'xyzw'[index]}`
        });

        generationContext.write(`${parentExpr} = ${vectorTypeName}(${components.join(', ')})${semicolon}`);
      } else {
        // Regular assignment
        const targetExpr = this.generateExpression(generationContext, dag, targetNodeID);
        const sourceExpr = this.generateExpression(generationContext, dag, sourceNodeID);

        // Generate assignment if we have both target and source
        if (targetExpr && sourceExpr && targetExpr !== sourceExpr) {
          generationContext.write(`${targetExpr} = ${sourceExpr}${semicolon}`);
        }
      }
    },
    generateDeclaration(generationContext, dag, nodeID) {
      const expr = this.generateExpression(generationContext, dag, nodeID);
      const tmp = `T${generationContext.nextTempID++}`;
      generationContext.tempNames[nodeID] = tmp;
      const T = extractNodeTypeInfo(dag, nodeID);
      const typeName = this.getTypeName(T.baseType, T.dimension);
      return `var ${tmp}: ${typeName} = ${expr};`;
    },
    generateReturnStatement(strandsContext, generationContext, rootNodeID, returnType) {
      if (!returnType) {
        generationContext.write('return;');
        return;
      }
      const dag = strandsContext.dag;
      const rootNode = getNodeDataFromID(dag, rootNodeID);
      if (isStructType(returnType) && rootNode.identifier) {
        const structTypeInfo = returnType;
        for (let i = 0; i < structTypeInfo.properties.length; i++) {
          const prop = structTypeInfo.properties[i];
          const val = this.generateExpression(generationContext, dag, rootNode.dependsOn[i]);
          if (prop.name !== val) {
            generationContext.write(
              `${rootNode.identifier}.${prop.name} = ${val};`
            );
          }
        }
      }
      generationContext.write(`return ${this.generateExpression(generationContext, dag, rootNodeID)};`);
    },
    generateExpression(generationContext, dag, nodeID) {
      const node = getNodeDataFromID(dag, nodeID);
      if (generationContext.tempNames?.[nodeID]) {
        return generationContext.tempNames[nodeID];
      }
      switch (node.nodeType) {
        case NodeType.LITERAL:
        if (node.baseType === BaseType.FLOAT) {
          return node.value.toFixed(4);
        }
        else {
          return node.value;
        }
        case NodeType.VARIABLE:
        // Track shared variable usage context
        if (generationContext.shaderContext && generationContext.strandsContext?.sharedVariables?.has(node.identifier)) {
          const sharedVar = generationContext.strandsContext.sharedVariables.get(node.identifier);
          if (generationContext.shaderContext === 'vertex') {
            sharedVar.usedInVertex = true;
          } else if (generationContext.shaderContext === 'fragment') {
            sharedVar.usedInFragment = true;
          }
        }

        // Detect instanceID usage in fragment context and rewrite to varying name
        if (node.identifier === this.instanceIdReference() && generationContext.shaderContext === 'fragment') {
          generationContext.strandsContext._instanceIDUsedInFragment = true;
          return INSTANCE_ID_VARYING_NAME;
        }

        // Check if this is a uniform variable (but not a texture or storage buffer)
        const uniform = generationContext.strandsContext?.uniforms?.find(uniform => uniform.name === node.identifier);
        if (uniform && uniform.typeInfo.baseType !== 'sampler2D' && uniform.typeInfo.baseType !== 'storage') {
          return `hooks.${node.identifier}`;
        }

        return node.identifier;
        case NodeType.OPERATION:
        const useParantheses = node.usedBy.length > 0;
        if (node.opCode === OpCode.Nary.CONSTRUCTOR) {
          // TODO: differentiate casts and constructors for more efficient codegen.
          // if (node.dependsOn.length === 1 && node.dimension === 1) {
          //   return this.generateExpression(generationContext, dag, node.dependsOn[0]);
          // }
          if (node.baseType === BaseType.SAMPLER2D) {
            return this.generateExpression(generationContext, dag, node.dependsOn[0]);
          }
          const T = this.getTypeName(node.baseType, node.dimension);
          const deps = node.dependsOn.map((dep) => this.generateExpression(generationContext, dag, dep));
          return `${T}(${deps.join(', ')})`;
        }
        if (node.opCode === OpCode.Nary.TERNARY) {
          const [condID, trueID, falseID] = node.dependsOn;
          const cond = this.generateExpression(generationContext, dag, condID);
          const trueExpr = this.generateExpression(generationContext, dag, trueID);
          const falseExpr = this.generateExpression(generationContext, dag, falseID);
          return `select(${falseExpr}, ${trueExpr}, ${cond})`;
        }
        if (node.opCode === OpCode.Nary.FUNCTION_CALL) {
          // Convert mod() function calls to % operator in WGSL
          if (node.identifier === 'mod' && node.dependsOn.length === 2) {
            const [leftID, rightID] = node.dependsOn;
            const left = this.generateExpression(generationContext, dag, leftID);
            const right = this.generateExpression(generationContext, dag, rightID);
            const useParantheses = node.usedBy.length > 0;
            if (useParantheses) {
              return `(${left} % ${right})`;
            } else {
              return `${left} % ${right}`;
            }
          }

          // Convert atan(y, x) to atan2(y, x) in WGSL
          if (node.identifier === 'atan' && node.dependsOn.length === 2) {
            const functionArgs = node.dependsOn.map(arg => this.generateExpression(generationContext, dag, arg));
            return `atan2(${functionArgs.join(', ')})`;
          }

          const functionArgs = node.dependsOn.map(arg =>this.generateExpression(generationContext, dag, arg));

          if (node.identifier === 'random') {
            const ctx = generationContext.shaderContext;
            if (ctx === 'fragment') {
              functionArgs.push('_p5FragPos.xy');
            } else if (ctx === 'vertex') {
              functionArgs.push('f32(_p5VertexId)');
            } else if (ctx === 'compute') {
              functionArgs.push('_p5GlobalId');
            }
          }

          return `${node.identifier}(${functionArgs.join(', ')})`;
        }
        if (node.opCode === OpCode.Binary.MEMBER_ACCESS) {
          const [lID, rID] = node.dependsOn;
          const lName = this.generateExpression(generationContext, dag, lID);
          const rName = this.generateExpression(generationContext, dag, rID);
          return `${lName}.${rName}`;
        }
        if (node.opCode === OpCode.Unary.SWIZZLE) {
          const parentID = node.dependsOn[0];
          const parentExpr = this.generateExpression(generationContext, dag, parentID);
          return `${parentExpr}.${node.swizzle}`;
        }
        if (node.opCode === OpCode.Binary.ARRAY_ACCESS) {
          const [bufferID, indexID] = node.dependsOn;
          const bufferExpr = this.generateExpression(generationContext, dag, bufferID);
          const indexExpr = this.generateExpression(generationContext, dag, indexID);
          const fieldSuffix = node.identifier ? `.${node.identifier}` : '';
          return `${bufferExpr}[i32(${indexExpr})]${fieldSuffix}`;
        }
        if (node.dependsOn.length === 2) {
          const [lID, rID] = node.dependsOn;
          const left  = this.generateExpression(generationContext, dag, lID);
          const right = this.generateExpression(generationContext, dag, rID);

          // In WGSL, % operator works for both floats and integers
          if (node.opCode === OpCode.Binary.MODULO) {
            return `(${left} % ${right})`;
          }

          const opSym = OpCodeToSymbol[node.opCode];
          if (useParantheses) {
            return `(${left} ${opSym} ${right})`;
          } else {
            return `${left} ${opSym} ${right}`;
          }
        }
        if (node.opCode === OpCode.Unary.LOGICAL_NOT
          || node.opCode === OpCode.Unary.NEGATE
          || node.opCode === OpCode.Unary.PLUS
          ) {
          const [i] = node.dependsOn;
          const val  = this.generateExpression(generationContext, dag, i);
          const sym  = OpCodeToSymbol[node.opCode];
          return `${sym}${val}`;
        }
        case NodeType.PHI:
        // Phi nodes represent conditional merging of values
        // If this phi node has an identifier (like varying variables), use that
        if (node.identifier) {
          return node.identifier;
        }
        // Otherwise, they should have been declared as temporary variables
        // and assigned in the appropriate branches
        if (generationContext.tempNames?.[nodeID]) {
          return generationContext.tempNames[nodeID];
        } else {
          // If no temp was created, this phi node only has one input
          // so we can just use that directly
          const validInputs = node.dependsOn.filter(id => id !== null);
          if (validInputs.length > 0) {
            return this.generateExpression(generationContext, dag, validInputs[0]);
          } else {
            throw new Error('No valid inputs for node');
          }
        }
        case NodeType.ASSIGNMENT:
        internalError(`ASSIGNMENT nodes should not be used as expressions`);
        default:
        internalError(`${NodeTypeToName[node.nodeType]} code generation not implemented yet`);
      }
    },
    generateBlock(blockID, strandsContext, generationContext) {
      const type = strandsContext.cfg.blockTypes[blockID];
      const handler = cfgHandlers[type] || cfgHandlers[BlockType.DEFAULT];
      handler.call(cfgHandlers, blockID, strandsContext, generationContext);
    },

    createGetTextureCall(strandsContext, args) {
      // In WebGPU, we need to add a sampler argument for the texture call
      // First argument should be a texture, second should be coordinates
      // We need to augment with a sampler argument based on the texture name
      const textureArg = args[0];
      const coordsArg = args[1];

      // Create a sampler variable node - add "_sampler" suffix to the texture identifier
      const { dag } = strandsContext;
      const textureNode = getNodeDataFromID(dag, textureArg.id);
      const samplerIdentifier = textureNode.identifier + '_sampler';

      const samplerVariable = variableNode(strandsContext, { baseType: BaseType.SAMPLER, dimension: 1 }, samplerIdentifier);
      const samplerNode = createStrandsNode(samplerVariable.id, samplerVariable.dimension, strandsContext);

      // Create a LOD literal node (0.0) so we can use textureSampleLevel instead
      // of textureSample. textureSample doesn't let you use uniform values in control
      // flow, whereas textureSampleLevel does. While we don't have mipmaps, we don't
      // miss out.
      // TODO: if we *do* add mipmap support, update this logic -- we'd need to hoist
      // the texture lookup out of the control flow.
      const lodLiteral = scalarLiteralNode(
        strandsContext,
        { dimension: 1, baseType: BaseType.FLOAT },
        0.0
      );
      const lodNode = createStrandsNode(lodLiteral.id, lodLiteral.dimension, strandsContext);

      // Create the augmented args: [texture, sampler, coords, lod]
      const augmentedArgs = [textureArg, samplerNode, coordsArg, lodNode];

      const { id, dimension } = functionCallNode(strandsContext, 'textureSampleLevel', augmentedArgs, {
        overloads: [{
          params: [DataType.sampler2D, DataType.sampler, DataType.float2, DataType.float1],
          returnType: DataType.float4
        }]
      });
      return { id, dimension };
    },

    instanceIdReference() {
      return 'instanceID';
    },

    generateInstanceIDVarying() {
      return { name: INSTANCE_ID_VARYING_NAME, declaration: `${INSTANCE_ID_VARYING_NAME}: i32`, source: 'i32(instanceID)', interpolation: 'flat' };
    },
  };

  const filterUniforms = `
// Group 0: Filter Properties
struct FilterUniforms {
  canvasSize: vec2<f32>,
  texelSize: vec2<f32>,
}

// Group 1: Model Transform
struct ModelUniforms {
  uModelViewMatrix: mat4x4<f32>,
}

// Group 2: Camera and Projection
struct CameraUniforms {
  uProjectionMatrix: mat4x4<f32>,
}

@group(0) @binding(0) var<uniform> filterParams: FilterUniforms;
@group(0) @binding(1) var tex0: texture_2d<f32>;
@group(0) @binding(2) var tex0_sampler: sampler;
@group(1) @binding(0) var<uniform> model: ModelUniforms;
@group(2) @binding(0) var<uniform> camera: CameraUniforms;
`;

  const baseFilterVertexShader = filterUniforms + `
struct VertexInput {
  @location(0) aPosition: vec3<f32>,
  @location(1) aTexCoord: vec2<f32>,
}

struct VertexOutput {
  @builtin(position) position: vec4<f32>,
  @location(0) vTexCoord: vec2<f32>,
}

@vertex
fn main(input: VertexInput) -> VertexOutput {
  var output: VertexOutput;

  // transferring texcoords for the frag shader
  output.vTexCoord = input.aTexCoord;

  // copy position with a fourth coordinate for projection (1.0 is normal)
  let positionVec4 = vec4<f32>(input.aPosition, 1.0);

  // project to 3D space
  output.position = camera.uProjectionMatrix * model.uModelViewMatrix * positionVec4;

  return output;
}
`;

  const baseFilterFragmentShader = filterUniforms + `
struct FilterInputs {
  texCoord: vec2<f32>,
  canvasSize: vec2<f32>,
  texelSize: vec2<f32>,
}

struct FragmentInput {
  @location(0) vTexCoord: vec2<f32>,
}

struct FragmentOutput {
  @location(0) color: vec4<f32>,
}

@fragment
fn main(input: FragmentInput) -> FragmentOutput {
  var output: FragmentOutput;
  var inputs: FilterInputs;
  inputs.texCoord = input.vTexCoord;
  inputs.canvasSize = filterParams.canvasSize;
  inputs.texelSize = filterParams.texelSize;

  var outColor = HOOK_getColor(inputs, tex0, tex0_sampler);
  outColor = vec4<f32>(outColor.rgb * outColor.a, outColor.a);
  output.color = outColor;

  return output;
}
`;

  const uniforms = `
struct Uniforms {
  uModelViewMatrix: mat4x4<f32>,
  uProjectionMatrix: mat4x4<f32>,
  uNormalMatrix: mat3x3<f32>,
  roughness: f32,
};
`;

  // Shared WGSL functions
  const sharedFunctions = `
const PI = 3.14159265359;

fn nTOE(v: vec3<f32>) -> vec2<f32> {
  // x = r sin(phi) cos(theta)
  // y = r cos(phi)
  // z = r sin(phi) sin(theta)
  let phi = acos(v.y);
  // if phi is 0, then there are no x, z components
  var theta = 0.0;
  // else
  theta = acos(v.x / sin(phi));
  let sinTheta = v.z / sin(phi);
  if (sinTheta < 0.0) {
    // Turn it into -theta, but in the 0-2PI range
    theta = 2.0 * PI - theta;
  }
  theta = theta / (2.0 * PI);
  let phiNorm = phi / PI;

  return vec2<f32>(phiNorm, theta);
}

fn random(p: vec2<f32>) -> f32 {
  let p3 = fract(vec3<f32>(p.x, p.y, p.x) * 0.1031);
  let dotP3 = dot(p3, p3.yzx + 33.33);
  return fract((p3.x + p3.y) * p3.z);
}
`;

  const imageLightVertexShader = `
struct VertexInput {
  @location(0) aPosition: vec3<f32>,
  @location(1) aNormal: vec3<f32>,
  @location(2) aTexCoord: vec2<f32>,
}

struct VertexOutput {
  @builtin(position) Position: vec4<f32>,
  @location(0) localPos: vec3<f32>,
  @location(1) vWorldNormal: vec3<f32>,
  @location(2) vWorldPosition: vec3<f32>,
  @location(3) vTexCoord: vec2<f32>,
}

${uniforms}
@group(0) @binding(0) var<uniform> uniforms: Uniforms;

@vertex
fn main(input: VertexInput) -> VertexOutput {
  var output: VertexOutput;

  // Multiply the position by the matrix
  let viewModelPosition = uniforms.uModelViewMatrix * vec4<f32>(input.aPosition, 1.0);
  output.Position = uniforms.uProjectionMatrix * viewModelPosition;

  // Orient the normals and pass to the fragment shader
  output.vWorldNormal = uniforms.uNormalMatrix * input.aNormal;

  // Send the view position to the fragment shader
  output.vWorldPosition = viewModelPosition.xyz;

  output.localPos = output.vWorldPosition;
  output.vTexCoord = input.aTexCoord;

  return output;
}
`;

  const imageLightDiffusedFragmentShader = `
struct FragmentInput {
  @location(0) localPos: vec3<f32>,
  @location(3) vTexCoord: vec2<f32>,
}

${uniforms}
@group(0) @binding(0) var<uniform> uniforms: Uniforms;
@group(0) @binding(1) var environmentMap: texture_2d<f32>;
@group(0) @binding(2) var environmentMap_sampler: sampler;

${sharedFunctions}

@fragment
fn main(input: FragmentInput) -> @location(0) vec4<f32> {
  // The sample direction equals the hemisphere's orientation
  let phi = input.vTexCoord.x * 2.0 * PI;
  let theta = input.vTexCoord.y * PI;
  let x = sin(theta) * cos(phi);
  let y = sin(theta) * sin(phi);
  let z = cos(theta);
  let normal = vec3<f32>(x, y, z);

  // Discretely sampling the hemisphere given the integral's
  // spherical coordinates translates to the following fragment code:
  var irradiance = vec3<f32>(0.0);
  let up = vec3<f32>(0.0, 1.0, 0.0);
  let right = normalize(cross(up, normal));
  let upNorm = normalize(cross(normal, right));

  // We specify a fixed sampleDelta delta value to traverse
  // the hemisphere; decreasing or increasing the sample delta
  // will increase or decrease the accuracy respectively.
  let sampleDelta = 0.100;
  var nrSamples = 0.0;
  let randomOffset = random(input.vTexCoord) * sampleDelta;

  for (var rawPhi = 0.0; rawPhi < 2.0 * PI; rawPhi += sampleDelta) {
    let phiSample = rawPhi + randomOffset;
    for (var rawTheta = 0.0; rawTheta < 0.5 * PI; rawTheta += sampleDelta) {
      let thetaSample = rawTheta + randomOffset;
      // spherical to cartesian (in tangent space) // tangent space to world // add each sample result to irradiance
      let xSample = sin(thetaSample) * cos(phiSample);
      let ySample = sin(thetaSample) * sin(phiSample);
      let zSample = cos(thetaSample);
      let tangentSample = vec3<f32>(xSample, ySample, zSample);

      let sampleVec = tangentSample.x * right + tangentSample.y * upNorm + tangentSample.z * normal;
      let envSample = textureSample(environmentMap, environmentMap_sampler, nTOE(sampleVec));
      irradiance += envSample.xyz * cos(thetaSample) * sin(thetaSample);
      nrSamples += 1.0;
    }
  }
  // divide by the total number of samples taken, giving us the average sampled irradiance.
  irradiance = PI * irradiance * (1.0 / nrSamples);

  return vec4<f32>(irradiance, 1.0);
}
`;

  const imageLightSpecularFragmentShader = `
struct FragmentInput {
  @location(0) localPos: vec3<f32>,
  @location(3) vTexCoord: vec2<f32>,
}

${uniforms}
@group(0) @binding(0) var<uniform> uniforms: Uniforms;
@group(0) @binding(1) var environmentMap: texture_2d<f32>;
@group(0) @binding(2) var environmentMap_sampler: sampler;

${sharedFunctions}

fn VanDerCorput(nIn: i32, base: i32) -> f32 {
  // Use the bit manipulation version for WebGPU (equivalent to WEBGL2 version)
  var n = u32(nIn);
  n = (n << 16u) | (n >> 16u);
  n = ((n & 0x55555555u) << 1u) | ((n & 0xAAAAAAAAu) >> 1u);
  n = ((n & 0x33333333u) << 2u) | ((n & 0xCCCCCCCCu) >> 2u);
  n = ((n & 0x0F0F0F0Fu) << 4u) | ((n & 0xF0F0F0F0u) >> 4u);
  n = ((n & 0x00FF00FFu) << 8u) | ((n & 0xFF00FF00u) >> 8u);
  return f32(n) * 2.3283064365386963e-10; // / 0x100000000
}

fn HammersleyNoBitOps(i: i32, N: i32) -> vec2<f32> {
  return vec2<f32>(f32(i) / f32(N), VanDerCorput(i, 2));
}

fn ImportanceSampleGGX(Xi: vec2<f32>, N: vec3<f32>, roughness: f32) -> vec3<f32> {
  let a = roughness * roughness;

  let phi = 2.0 * PI * Xi.x;
  let cosTheta = sqrt((1.0 - Xi.y) / (1.0 + (a * a - 1.0) * Xi.y));
  let sinTheta = sqrt(1.0 - cosTheta * cosTheta);

  // from spherical coordinates to cartesian coordinates
  var H: vec3<f32>;
  H.x = cos(phi) * sinTheta;
  H.y = sin(phi) * sinTheta;
  H.z = cosTheta;

  // from tangent-space vector to world-space sample vector
  let up = select(vec3<f32>(0.0, 0.0, 1.0), vec3<f32>(1.0, 0.0, 0.0), abs(N.z) < 0.999);
  let tangent = normalize(cross(up, N));
  let bitangent = cross(N, tangent);

  let sampleVec = tangent * H.x + bitangent * H.y + N * H.z;
  return normalize(sampleVec);
}

@fragment
fn main(input: FragmentInput) -> @location(0) vec4<f32> {
  let SAMPLE_COUNT = 400i; // 4096
  let lowRoughnessLimit = i32(pow(2.0, (uniforms.roughness + 0.1) * 20.0));
  var totalWeight = 0.0;
  var prefilteredColor = vec3<f32>(0.0);
  let phi = input.vTexCoord.x * 2.0 * PI;
  let theta = input.vTexCoord.y * PI;
  let x = sin(theta) * cos(phi);
  let y = sin(theta) * sin(phi);
  let z = cos(theta);
  let N = vec3<f32>(x, y, z);
  let V = N;

  for (var i = 0i; i < SAMPLE_COUNT; i++) {
    // break at smaller sample numbers for low roughness levels
    if (i == lowRoughnessLimit) {
      break;
    }
    let Xi = HammersleyNoBitOps(i, SAMPLE_COUNT);
    let H = ImportanceSampleGGX(Xi, N, uniforms.roughness);
    let L = normalize(2.0 * dot(V, H) * H - V);

    let NdotL = max(dot(N, L), 0.0);
    // Always sample the texture to maintain uniform control flow
    let envSample = textureSample(environmentMap, environmentMap_sampler, nTOE(L));
    // Only add to accumulators if NdotL > 0
    if (NdotL > 0.0) {
      prefilteredColor += envSample.xyz * NdotL;
      totalWeight += NdotL;
    }
  }
  prefilteredColor = prefilteredColor / totalWeight;

  return vec4<f32>(prefilteredColor, 1.0);
}
`;

  const baseComputeShader = `
struct ComputeUniforms {
  uTotalCount: vec3<i32>,
  uPhysicalCount: vec3<i32>,
}
@group(0) @binding(0) var<uniform> uniforms: ComputeUniforms;

@compute @workgroup_size(8, 8, 1)
fn main(
  @builtin(global_invocation_id) globalId: vec3<u32>,
  @builtin(local_invocation_id) localId: vec3<u32>,
  @builtin(workgroup_id) workgroupId: vec3<u32>,
  @builtin(local_invocation_index) localIndex: u32
) {
  let totalIterations = u32(uniforms.uTotalCount.x) * u32(uniforms.uTotalCount.y) * u32(uniforms.uTotalCount.z);
  let physicalId = globalId.x + globalId.y * (u32(uniforms.uPhysicalCount.x)) + globalId.z * (u32(uniforms.uPhysicalCount.x) * u32(uniforms.uPhysicalCount.y));

  if (physicalId >= totalIterations) {
    return;
  }

  var index = vec3<i32>(0);
  index.x = i32(physicalId % u32(uniforms.uTotalCount.x));
  let remainingY = physicalId / u32(uniforms.uTotalCount.x);
  index.y = i32(remainingY % u32(uniforms.uTotalCount.y));
  index.z = i32(remainingY / u32(uniforms.uTotalCount.y));

  HOOK_iteration(index);
}
`;

  /**
   * @module 3D
   * @submodule p5.strands
   * @for p5
   */


  const FRAME_STATE = {
    PENDING: 0,
    UNPROMOTED: 1,
    PROMOTED: 2
  };

  function rendererWebGPU(p5, fn) {
    const { lineDefs } = getStrokeDefs((n, v, t) => `const ${n}: ${t} = ${v};\n`);

    // RendererWebGPU depends on these other classes being set up prior,
    // as it is optimized for being in a standalone build, not core
    const {
      Renderer3D,
      Shader,
      Texture,
      MipmapTexture,
      Image,
      Camera,
      RGBA,
    } = p5;

    class StorageBuffer {
      constructor(buffer, size, renderer, schema = null) {
        this._isStorageBuffer = true;
        this.buffer = buffer;
        this.size = size;
        this._renderer = renderer;
        this._schema = schema;
      }

      /**
       * Updates the data in the buffer with new values. The new data must be in
       * the same format as the data originally passed to
       * <a href="#/p5/createStorage">`createStorage()`</a>.
       *
       * ```js example
       * let particles;
       * let computeShader;
       * let displayShader;
       * let instance;
       * const numParticles = 100;
       *
       * async function setup() {
       *   await createCanvas(100, 100, WEBGPU);
       *   particles = createStorage(makeParticles(width / 2, height / 2));
       *   computeShader = buildComputeShader(simulate);
       *   displayShader = buildMaterialShader(display);
       *   instance = buildGeometry(drawParticle);
       *   describe('100 orange particles shooting outward.');
       * }
       *
       * function makeParticles(x, y) {
       *   let data = [];
       *   for (let i = 0; i < numParticles; i++) {
       *     let angle = (i / numParticles) * TWO_PI;
       *     let speed = random(0.5, 2);
       *     data.push({
       *       position: createVector(x, y),
       *       velocity: createVector(cos(angle) * speed, sin(angle) * speed),
       *     });
       *   }
       *   return data;
       * }
       *
       * function drawParticle() {
       *   sphere(2);
       * }
       *
       * function simulate() {
       *   let data = uniformStorage(particles);
       *   let idx = index.x;
       *   data[idx].position = data[idx].position + data[idx].velocity;
       * }
       *
       * function display() {
       *   let data = uniformStorage(particles);
       *   worldInputs.begin();
       *   let pos = data[instanceID()].position;
       *   worldInputs.position.xy += pos - [width / 2, height / 2];
       *   worldInputs.end();
       * }
       *
       * function draw() {
       *   background(30);
       *   if (frameCount % 60 === 0) {
       *     particles.update(makeParticles(random(width), random(height)));
       *   }
       *   compute(computeShader, numParticles);
       *   noStroke();
       *   fill(255, 200, 50);
       *   shader(displayShader);
       *   model(instance, numParticles);
       * }
       * ```
       *
       * @method update
       * @for p5.StorageBuffer
       * @beta
       * @webgpu
       * @webgpuOnly
       * @param {Number[]|Float32Array|Object[]} data The new data to write into the buffer.
       */
      update(data) {
        const device = this._renderer.device;

        if (this._schema !== null) {
          // Buffer was created with a struct array
          if (
            !Array.isArray(data) ||
            data.length === 0 ||
            typeof data[0] !== 'object' ||
            Array.isArray(data[0])
          ) {
            throw new Error(
              'update() expects an array of objects matching the original struct format'
            );
          }

          const newSchema = this._renderer._inferStructSchema(data[0]);
          if (newSchema.structBody !== this._schema.structBody) {
            throw new Error(
              `update() data structure doesn't match the original.\n` +
              `  Expected: ${this._schema.structBody}\n` +
              `  Got:      ${newSchema.structBody}`
            );
          }

          const packed = this._renderer._packStructArray(data, this._schema);
          if (packed.byteLength > this.size) {
            throw new Error(
              `update() data (${packed.byteLength} bytes) exceeds buffer size (${this.size} bytes)`
            );
          }
          device.queue.writeBuffer(this.buffer, 0, packed);
        } else {
          // Buffer was created with a float array
          let floatData;
          if (data instanceof Float32Array) {
            floatData = data;
          } else if (Array.isArray(data)) {
            floatData = new Float32Array(data);
          } else {
            throw new Error(
              'update() expects a Float32Array or array of numbers for this buffer'
            );
          }

          if (floatData.byteLength > this.size) {
            throw new Error(
              `update() data (${floatData.byteLength} bytes) exceeds buffer size (${this.size} bytes)`
            );
          }
          device.queue.writeBuffer(this.buffer, 0, floatData);
        }
      }

      /**
       * Reads data from a storage buffer back into JavaScript.
       *
       * Copies data from the GPU to the CPU using a temporary buffer,
       * so it must be awaited. Returns a `Float32Array` for number
       * buffers, or an array of plain objects for struct buffers.
       * 
       * Note: This is a GPU -> CPU read, so calling it often (like every frame)
       * can be slow.
       *
       * ```js example
       * let data;
       * let computeShader;
       *
       * async function setup() {
       *   await createCanvas(100, 100, WEBGPU);
       *
       *   data = createStorage(new Float32Array([1, 2, 3, 4]));
       *   computeShader = buildComputeShader(doubleValues);
       *   compute(computeShader, 4);
       *
       *   let result = await data.read();
       *   // result is Float32Array [2, 4, 6, 8]
       *   for (let i = 0; i < result.length; i++) {
       *     print(result[i]);
       *   }
       *   describe('Prints the values 2, 4, 6, 8 to the console.');
       * }
       *
       * function doubleValues() {
       *   let d = uniformStorage(data);
       *   let idx = index.x;
       *   d[idx] = d[idx] * 2;
       * }
       * ```
       *
       * @method read
       * @for p5.StorageBuffer
       * @beta
       * @webgpu
       * @webgpuOnly
       * @returns {Promise<Float32Array|Object[]>}
       */
      async read() {
        const device = this._renderer.device;
        this._renderer.flushDraw();

        const stagingBuffer = device.createBuffer({
          size: this.size,
          usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ,
        });

        const commandEncoder = device.createCommandEncoder();
        commandEncoder.copyBufferToBuffer(this.buffer, 0, stagingBuffer, 0, this.size);
        device.queue.submit([commandEncoder.finish()]);

        await stagingBuffer.mapAsync(GPUMapMode.READ, 0, this.size);
        const mappedRange = stagingBuffer.getMappedRange(0, this.size);

        // Copy before unmapping because mapped memory becomes invalid after unmap
        const rawCopy = new Float32Array(mappedRange.byteLength / 4);
        rawCopy.set(new Float32Array(mappedRange));

        stagingBuffer.unmap();
        stagingBuffer.destroy();

        if (this._schema !== null) {
          return this._renderer._unpackStructArray(rawCopy, this._schema);
        }
        return rawCopy;
      }

      /**
       * Updates a single element in the buffer at a given index. Use this
       * when only a small number of elements need to change. If you need to
       * replace all the data at once, use
       * <a href="#/p5.StorageBuffer/update">`update()`</a> instead.
       *
       * ```js
       * let buf;
       *
       * async function setup() {
       *   await createCanvas(100, 100, WEBGPU);
       *
       *   // Float buffer: update one value by index
       *   buf = createStorage(new Float32Array([1, 2, 3, 4]));
       *   buf.set(2, 9.5); // only index 2 changes → [1, 2, 9.5, 4]
       *
       *   let result = await buf.read();
       *   print(result[2]); // 9.5
       *   describe('Prints 9.5 to the console.');
       * }
       * ```
       *
       * ```js
       * let particles;
       * const numParticles = 100;
       *
       * async function setup() {
       *   await createCanvas(100, 100, WEBGPU);
       *   particles = createStorage(makeParticles());
       *
       *   // Replace particle 42 without touching the others
       *   particles.set(42, {
       *     position: createVector(0, 0),
       *     velocity: createVector(1, 0),
       *   });
       *
       *   // Read back to confirm the update
       *   let result = await particles.read();
       *   print(result[42].position.x, result[42].position.y); // 0, 0
       *   describe('Prints the position of particle 42 after updating it.');
       * }
       *
       * function makeParticles() {
       *   let data = [];
       *   for (let i = 0; i < numParticles; i++) {
       *     data.push({
       *       position: createVector(random(width), random(height)),
       *       velocity: createVector(random(-1, 1), random(-1, 1)),
       *     });
       *   }
       *   return data;
       * }
       * ```
       *
       * @method set
       * @for p5.StorageBuffer
       * @beta
       * @webgpu
       * @webgpuOnly
       * @param {Number} index The zero-based index of the element to update.
       * @param {Number|Object} value The new value. Pass a number for float
       *   buffers, or a plain object matching the original struct layout for
       *   struct buffers.
       */
      set(index, value) {
        const device = this._renderer.device;

        if (this._schema !== null) {
          // buffer was created with an array of structs
          if (typeof value !== 'object' || value === null || Array.isArray(value)) {
            throw new Error(
              'set() expects a plain object matching the original struct format for this buffer'
            );
          }

          const { stride } = this._schema;
          const byteOffset = index * stride;

          if (byteOffset + stride > this.size) {
            throw new Error(
              `set() index ${index} is out of bounds for this buffer ` +
              `(buffer holds ${Math.floor(this.size / stride)} elements)`
            );
          }

          // pack just this one element using the same logic as update()
          const packed = this._renderer._packStructArray([value], this._schema);
          // use packed.buffer (ArrayBuffer) so the size arg is always in bytes
          device.queue.writeBuffer(this.buffer, byteOffset, packed.buffer, 0, stride);
        } else {
          // buffer was created with a float array
          if (typeof value !== 'number') {
            throw new Error(
              'set() expects a number for this float buffer'
            );
          }

          const byteOffset = index * 4;

          if (byteOffset + 4 > this.size) {
            throw new Error(
              `set() index ${index} is out of bounds for this buffer ` +
              `(buffer holds ${Math.floor(this.size / 4)} floats)`
            );
          }

          device.queue.writeBuffer(this.buffer, byteOffset, new Float32Array([value]));
        }
      }
    }

    /**
     * A block of data that shaders can read from, and compute shaders can also
     * write to. This is only available in WebGPU mode.
     *
     * Note: <a href="#/p5/createStorage">`createStorage()`</a> is the recommended
     * way to create an instance of this class.
     *
     * @class p5.StorageBuffer
     * @beta
     * @webgpu
     * @webgpuOnly
     */
    p5.StorageBuffer = StorageBuffer;

    class RendererWebGPU extends Renderer3D {
      constructor(pInst, w, h, isMainCanvas, elt) {
        super(pInst, w, h, isMainCanvas, elt);

        // Used to group draws into one big render pass
        this.activeRenderPass = null;
        this.activeRenderPassEncoder = null;
        this.activeShaderOptions = null;
        this.activeShader = null;

        this.samplers = new Map();

        // Some uniforms update every frame, like model matrices and sometimes colors.
        // The fastest way to handle these is to use mapped memory. We'll batch those
        // into bigger buffers with dynamic offsets, separate from the usual system
        // where bind groups have their own little buffers that get cached when they
        // are unchanged
        this.uniformBufferAlignment = 256;
        this.activeUniformBuffers = [];
        this.currentUniformBuffer = undefined;
        this.uniformBufferPool = [];
        this.resettingUniformBuffers = [];

        this.dynamicEntryOffsets = new Uint32Array(64);

        // Cache for current frame's canvas texture view
        this.currentCanvasColorTexture = null;
        this.currentCanvasColorTextureView = null;

        // Single reusable staging buffer for pixel reading
        this.pixelReadBuffer = null;
        this.pixelReadBufferSize = 0;

        this.strandsBackend = wgslBackend;

        // Registry to track all shaders for uniform data pooling
        this._shadersWithPools = [];

        // Registry to track geometries with buffer pools
        this._geometriesWithPools = [];

        // Flag to track if any draws have happened that need queue submission
        this._hasPendingDraws = false;
        this._pendingCommandEncoders = [];

        // Queue of callbacks to run after next submit (mainly for safe texture deletion)
        this._postSubmitCallbacks = [];

        // Retired buffers to destroy at end of frame
        this._retiredBuffers = [];

        // Storage buffers for compute shaders
        this._storageBuffers = new Set();

        // 2D canvas for pixel reading fallback
        this._pixelReadCanvas = null;
        this._pixelReadCtx = null;
        this.mainFramebuffer = null;
        this._frameState = FRAME_STATE.PENDING;

        this.finalCamera = new Camera(this);
        this.finalCamera._computeCameraDefaultSettings();
        this.finalCamera._setDefaultCamera();

        this.depthFormat = 'depth24plus-stencil8';
        this.depthTexture = null;
        this.depthTextureView = null;
      }

      async setupContext() {
        this._setAttributeDefaults(this._pInst);
        await this._initContext();
      }

      _setAttributeDefaults(pInst) {
        const defaults = {
          forceFallbackAdapter: false,
          powerPreference: 'high-performance',
        };
        if (pInst._webgpuAttributes === null) {
          pInst._webgpuAttributes = defaults;
        } else {
          pInst._webgpuAttributes = Object.assign(defaults, pInst._webgpuAttributes);
        }
        return;
      }

      async _initContext() {
        this.adapter = await navigator.gpu?.requestAdapter(this._webgpuAttributes);
        this.device = await this.adapter?.requestDevice({
          // Todo: check support
          requiredFeatures: ['depth32float-stencil8']
        });
        if (!this.device) {
          throw new Error('Your browser does not support WebGPU.');
        }
        this.queue = this.device.queue;
        this.drawingContext = this.canvas.getContext('webgpu');
        this.presentationFormat = navigator.gpu.getPreferredCanvasFormat();
        this.drawingContext.configure({
          device: this.device,
          format: this.presentationFormat,
          usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.COPY_SRC,
          alphaMode: 'premultiplied',
        });

        // TODO disablable stencil
        this.mainFramebuffer = this.createFramebuffer({ _useCanvasFormat: true });
        this._updateSize();
        this._update();
        this.flushDraw();
      }

      async _setAttributes(key, value) {
        if (typeof this._pInst._webgpuAttributes === "undefined") {
          console.log(
            "You are trying to use setAttributes on a p5.Graphics object " +
            "that does not use a WebGPU renderer."
          );
          return;
        }
        let unchanged = true;

        if (typeof value !== "undefined") {
          //first time modifying the attributes
          if (this._pInst._webgpuAttributes === null) {
            this._pInst._webgpuAttributes = {};
          }
          if (this._pInst._webgpuAttributes[key] !== value) {
            //changing value of previously altered attribute
            this._pInst._webgpuAttributes[key] = value;
            unchanged = false;
          }
          //setting all attributes with some change
        } else if (key instanceof Object) {
          if (this._pInst._webgpuAttributes !== key) {
            this._pInst._webgpuAttributes = key;
            unchanged = false;
          }
        }
        //@todo_FES
        if (!this.isP3D || unchanged) {
          return;
        }

        if (!this._pInst._setupDone) {
          if (this.geometryBufferCache.numCached() > 0) {
            p5._friendlyError(
              "Sorry, Could not set the attributes, you need to call setAttributes() " +
              "before calling the other drawing methods in setup()"
            );
            return;
          }
        }

        await this._resetContext(null, null, RendererWebGPU);

        if (this.states.curCamera) {
          this.states.curCamera._renderer = this._renderer;
        }
      }

      _updateSize() {
        if (!this.device || !this.depthFormat) return;
        if (this.depthTexture && this.depthTexture.destroy) {
          this.flushDraw();
          const textureToDestroy = this.depthTexture;
          this._postSubmitCallbacks.push(() => textureToDestroy.destroy());
          this.depthTextureView = null;
        }
        this.depthTexture = this.device.createTexture({
          size: {
            width: Math.ceil(this.width * this._pixelDensity),
            height: Math.ceil(this.height * this._pixelDensity),
            depthOrArrayLayers: 1,
          },
          format: this.depthFormat,
          usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.COPY_SRC,
        });
        this.depthTextureView = this.depthTexture.createView();

        // Clear the main canvas after resize
        this.clear();
      }

      _getCanvasColorTextureView() {
        const canvasTexture = this.drawingContext.getCurrentTexture();
        // If texture changed (new frame), update cache
        if (this.currentCanvasColorTexture !== canvasTexture) {
          this.currentCanvasColorTexture = canvasTexture;
          this.currentCanvasColorTextureView = canvasTexture.createView();
        }
        return this.currentCanvasColorTextureView;
      }

      _beginActiveRenderPass() {
        if (this.activeRenderPass) return;

        // Use framebuffer texture if active, otherwise use canvas texture
        const activeFramebuffer = this.activeFramebuffer();

        const colorAttachment = {
          view: activeFramebuffer
            ? (activeFramebuffer.aaColorTexture
                ? activeFramebuffer.aaColorTextureView
                : activeFramebuffer.colorTextureView)
            : this._getCanvasColorTextureView(),
          loadOp: "load",
          storeOp: "store",
          // If using multisampled texture, resolve to non-multisampled texture
          resolveTarget: activeFramebuffer && activeFramebuffer.aaColorTexture
            ? activeFramebuffer.colorTextureView
            : undefined,
        };

        // Use framebuffer depth texture if active, otherwise use canvas depth texture
        const depthTextureView = activeFramebuffer
          ? (activeFramebuffer.aaDepthTexture
              ? activeFramebuffer.aaDepthTextureView
              : activeFramebuffer.depthTextureView)
          : this.depthTextureView;
        const renderPassDescriptor = {
          colorAttachments: [colorAttachment],
          depthStencilAttachment: depthTextureView
            ? {
                view: depthTextureView,
                depthLoadOp: "load",
                depthStoreOp: "store",
                depthClearValue: 1.0,
                stencilLoadOp: "load",
                stencilStoreOp: "store",
                depthReadOnly: false,
                stencilReadOnly: false,
              }
            : undefined,
        };
        const commandEncoder = this.device.createCommandEncoder();
        const passEncoder = commandEncoder.beginRenderPass(renderPassDescriptor);
        this.activeRenderPassEncoder = commandEncoder;
        this.activeRenderPass = passEncoder;
      }

      _finishActiveRenderPass() {
        if (!this.activeRenderPass) return;

        const commandEncoder = this.activeRenderPassEncoder;
        const passEncoder = this.activeRenderPass;
        passEncoder.end();

        // Store the command encoder for later submission
        this._pendingCommandEncoders.push(commandEncoder.finish());
        this.activeRenderPassEncoder = null;
        this.activeRenderPass = null;
        this.activeShader = null;
        this.activeShaderOptions = null;
      }

      clear(...args) {
        if (!this.device || !this.drawingContext) return;
        const _r = args[0] || 0;
        const _g = args[1] || 0;
        const _b = args[2] || 0;
        const _a = args[3] || 0;

        // If PENDING and no custom framebuffer, clear means stay UNPROMOTED.
        // However, if we are still in setup (frameCount == 0), we must promote
        // so that mainFramebuffer gets the cleared content. This ensures that if
        // draw() later promotes without a copy, it starts from the correct state
        // rather than a stale mainFramebuffer.
        // Note: a mid-draw-loop transition from UNPROMOTED back to PROMOTED
        // (i.e. calling background() some frames but not others) will still
        // lose intermediate UNPROMOTED frame content.
        if (this._frameState !== FRAME_STATE.PROMOTED && !this.activeFramebuffer()) {
          if (this._pInst.frameCount > 0) {
            this._frameState = FRAME_STATE.UNPROMOTED;
          } else {
            this._promoteToFramebufferWithoutCopy();
            // clear() then targets mainFramebuffer via activeFramebuffer()
          }
        }

        this._finishActiveRenderPass();

        const commandEncoder = this.device.createCommandEncoder();

        // Use framebuffer texture if active, otherwise use canvas texture
        const activeFramebuffer = this.activeFramebuffer();

        const colorAttachment = {
          view: activeFramebuffer
            ? (activeFramebuffer.aaColorTexture
                ? activeFramebuffer.aaColorTextureView
                : activeFramebuffer.colorTextureView)
            : this._getCanvasColorTextureView(),
          clearValue: { r: _r * _a, g: _g * _a, b: _b * _a, a: _a },
          loadOp: 'clear',
          storeOp: 'store',
          // If using multisampled texture, resolve to non-multisampled texture
          resolveTarget: activeFramebuffer && activeFramebuffer.aaColorTexture
            ? activeFramebuffer.colorTextureView
            : undefined,
        };

        // Use framebuffer depth texture if active, otherwise use canvas depth texture
        const depthTextureView = activeFramebuffer
          ? (activeFramebuffer.aaDepthTexture
              ? activeFramebuffer.aaDepthTextureView
              : activeFramebuffer.depthTextureView)
          : this.depthTextureView;
        const depthAttachment = depthTextureView
          ? {
            view: depthTextureView,
            depthClearValue: 1.0,
            depthLoadOp: 'clear',
            depthStoreOp: 'store',
            stencilLoadOp: 'load',
            stencilStoreOp: 'store',
          }
          : undefined;

        const renderPassDescriptor = {
          colorAttachments: [colorAttachment],
          ...(depthAttachment ? { depthStencilAttachment: depthAttachment } : {}),
        };

        const passEncoder = commandEncoder.beginRenderPass(renderPassDescriptor);
        passEncoder.end();

        this._pendingCommandEncoders.push(commandEncoder.finish());
        this._hasPendingDraws = true;
      }

      /**
       * Resets all depth information so that nothing previously drawn will
       * occlude anything subsequently drawn.
       */
      clearDepth(depth = 1) {
        if (!this.device || !this.depthTextureView) return;
        this._finishActiveRenderPass();
        const commandEncoder = this.device.createCommandEncoder();

        // Use framebuffer texture if active, otherwise use canvas texture
        const activeFramebuffer = this.activeFramebuffer();

        // Use framebuffer depth texture if active, otherwise use canvas depth texture
        const depthTextureView = activeFramebuffer
          ? (activeFramebuffer.aaDepthTexture
              ? activeFramebuffer.aaDepthTextureView
              : activeFramebuffer.depthTextureView)
          : this.depthTextureView;

        if (!depthTextureView) {
          // No depth buffer to clear
          return;
        }

        const depthAttachment = {
          view: depthTextureView,
          depthClearValue: depth,
          depthLoadOp: 'clear',
          depthStoreOp: 'store',
          stencilLoadOp: 'load',
          stencilStoreOp: 'store',
        };

        const renderPassDescriptor = {
          colorAttachments: [], // No color attachments, we're only clearing depth
          depthStencilAttachment: depthAttachment,
        };

        const passEncoder = commandEncoder.beginRenderPass(renderPassDescriptor);
        passEncoder.end();

        this._pendingCommandEncoders.push(commandEncoder.finish());
        this._hasPendingDraws = true;
      }

      _prepareBuffer(renderBuffer, geometry, shader) {
        const attr = shader.attributes[renderBuffer.attr];
        if (!attr) return;

        const { src, dst, size, map } = renderBuffer;

        const device = this.device;
        const buffers = this._getOrMakeCachedBuffers(geometry);
        let srcData = geometry[src];
        if (!srcData || srcData.length === 0) {
          if (renderBuffer.default) {
            srcData = geometry[src] = renderBuffer.default(geometry);
            srcData.isDefault = true;
          } else {
            return;
          }
        }

        // Check if we already have a buffer for this data
        let existingBuffer = buffers[dst];
        const needsNewBuffer = !existingBuffer;

        // Only create new buffer and write data if buffer doesn't exist or data is dirty
        if (needsNewBuffer || geometry.dirtyFlags[src] !== false) {
          const raw = map ? map(srcData) : srcData;
          const typed = this._normalizeBufferData(raw, Float32Array);

          // Get pooled buffer (may reuse existing or create new)
          const pooledBufferInfo = this._getVertexBufferFromPool(geometry, dst, typed.byteLength);

          // Create a copy of the data to avoid conflicts when geometry arrays are reset
          const dataCopy = new typed.constructor(typed);
          pooledBufferInfo.dataCopy = dataCopy;

          // Write the data to the pooled buffer
          device.queue.writeBuffer(pooledBufferInfo.buffer, 0, dataCopy);

          // Update the buffers cache to use the pooled buffer
          buffers[dst] = pooledBufferInfo.buffer;
          geometry.dirtyFlags[src] = false;
        }

        shader.enableAttrib(attr, size);
      }

      _disableRemainingAttributes(shader) {}

      _enableAttrib(attr) {
        // TODO: is this necessary?
        const loc = attr.location;
        if (!this.registerEnabled.has(loc)) {
          // TODO
          this.registerEnabled.add(loc);
        }
      }

      _ensureGeometryBuffers(buffers, indices, indexType) {
        if (!indices) return;

        const device = this.device;

        const buffer = device.createBuffer({
          size: Math.ceil((indices.length * indexType.BYTES_PER_ELEMENT) / 4) * 4,
          usage: GPUBufferUsage.INDEX | GPUBufferUsage.COPY_DST,
          mappedAtCreation: true,
        });

        // Write index data to buffer
        const mapping = new indexType(buffer.getMappedRange());
        mapping.set(indices);
        buffer.unmap();

        buffers.indexBuffer = buffer;
        buffers.indexBufferType = indexType === Uint32Array ? 'uint32' : 'uint16';
      }

      _freeBuffers(buffers) {
        const destroyIfExists = (buf) => {
          if (buf && buf.destroy) {
            buf.destroy();
          }
        };

        destroyIfExists(buffers.indexBuffer);

        const freeDefs = (defs) => {
          for (const def of defs) {
            destroyIfExists(buffers[def.dst]);
            buffers[def.dst] = null;
          }
        };

        freeDefs(this.buffers.stroke);
        freeDefs(this.buffers.fill);
        freeDefs(this.buffers.user);
      }

      _getValidSampleCount(requestedCount) {
        // WebGPU supports sample counts of 1, 4 (and sometimes 8)
        if (requestedCount <= 1) return 1;
        if (requestedCount <= 4) return 4;
        return 4; // Cap at 4 for broader compatibility
      }

      _shaderOptions({ mode, compute, workgroupSize }) {
        if (compute) return { compute: true, workgroupSize };
        const activeFramebuffer = this.activeFramebuffer();
        const format = activeFramebuffer ?
          this._getWebGPUColorFormat(activeFramebuffer) :
          this.presentationFormat;

        const requestedSampleCount = activeFramebuffer ?
          (activeFramebuffer.antialias ? activeFramebuffer.antialiasSamples : 1) :
          1;  // No MSAA needed when blitting already-antialiased textures to canvas
        const sampleCount = this._getValidSampleCount(requestedSampleCount);

        const depthFormat = activeFramebuffer
          ? (activeFramebuffer.useDepth ? this._getWebGPUDepthFormat(activeFramebuffer) : undefined)
          : this.depthFormat;

        const drawTarget = this.drawTarget();
        const clipping = this._clipping;
        const clipApplied = drawTarget._isClipApplied;

        return {
          topology: mode === TRIANGLE_STRIP ? 'triangle-strip' : 'triangle-list',
          blendMode: this.states.curBlendMode,
          sampleCount,
          format,
          depthFormat,
          clipping,
          clipApplied,
        }
      }

      _shaderOptionsDifferent(newOptions) {
        if (!this.activeShaderOptions) return true;
        for (const key in this.activeShaderOptions) {
          if (this.activeShaderOptions[key] !== newOptions[key]) return true;
        }
        return false;
      }

      _initShader(shader) {
        const device = this.device;

        if (shader.shaderType === 'compute') {
          // Compute shader initialization
          shader.computeModule = device.createShaderModule({ code: shader.computeSrc() });
          shader._computePipelineCache = null;
          shader._workgroupSize = null;

          // Create compute pipeline (deferred until first compute() call)
          shader.getPipeline = ({ workgroupSize }) => {
            if (!shader._computePipelineCache) {
              shader._computePipelineCache = device.createComputePipeline({
                layout: shader._pipelineLayout,
                compute: {
                  module: shader.computeModule,
                  entryPoint: 'main'
                }
              });
              shader._workgroupSize = workgroupSize;
            }
            return shader._computePipelineCache;
          };

          return;
        }

        // Render shader initialization
        shader.vertModule = device.createShaderModule({ code: shader.vertSrc() });
        shader.fragModule = device.createShaderModule({ code: shader.fragSrc() });

        shader._pipelineCache = new Map();
        shader.getPipeline = ({ topology, blendMode, sampleCount, format, depthFormat, clipping, clipApplied }) => {
          const key = `${topology}_${blendMode}_${sampleCount}_${format}_${depthFormat}_${clipping}_${clipApplied}`;
          if (!shader._pipelineCache.has(key)) {
            const pipeline = device.createRenderPipeline({
              layout: shader._pipelineLayout,
              vertex: {
                module: shader.vertModule,
                entryPoint: 'main',
                buffers: this._getVertexLayout(shader),
              },
              fragment: {
                module: shader.fragModule,
                entryPoint: 'main',
                targets: [{
                  format,
                  blend: this._getBlendState(blendMode),
                }],
              },
              primitive: { topology },
              multisample: { count: sampleCount },
              ...(depthFormat ? {
                depthStencil: {
                  format: depthFormat,
                  depthWriteEnabled: !clipping,
                  depthCompare: 'less-equal',
                  stencilFront: {
                    compare: clipping ? 'always' : (clipApplied ? 'not-equal' : 'always'),
                    failOp: 'keep',
                    depthFailOp: 'keep',
                    passOp: clipping ? 'replace' : 'keep',
                  },
                  stencilBack: {
                    compare: clipping ? 'always' : (clipApplied ? 'not-equal' : 'always'),
                    failOp: 'keep',
                    depthFailOp: 'keep',
                    passOp: clipping ? 'replace' : 'keep',
                  },
                  stencilReadMask: 0xFF,
                  stencilWriteMask: clipping ? 0xFF : 0x00,
                },
              } : {}),
            });
            shader._pipelineCache.set(key, pipeline);
          }
          return shader._pipelineCache.get(key);
        };
      }

      _finalizeShader(shader) {
        // Per-group buffer pools. We will pull from these when we draw multiple
        // times using the shader in a render pass. These are per group instead of
        // global so that we can reuse the last used buffer when uniform values
        // don't change.
        shader._uniformBufferGroups = [];
        shader.buffersDirty = new Set();

        for (const group of shader._uniformGroups) {
          // Calculate the size needed for this group's uniforms
          const groupUniforms = Object.values(group.uniforms);
          const rawSize = Math.max(
            0,
            ...groupUniforms.map(u => u.offsetEnd)
          );
          const alignedSize = Math.ceil(rawSize / 16) * 16;

          shader._uniformBufferGroups.push({
            group: group.group,
            binding: group.binding,
            cacheKey: group.group * 1000 + group.binding,
            varName: group.varName,
            structType: group.structType,
            uniforms: groupUniforms,
            size: alignedSize,

            bufferPool: [],
            nextBufferPool: [],

            dynamic: groupUniforms.some(u => u.name.startsWith('uModel')),
            buffersInUse: new Set(),
            currentBuffer: null, // For caching
          });
        }

        // Register this shader in our registry for pool cleanup
        this._shadersWithPools.push(shader);

        const bindGroupLayouts = new Map(); // group index -> bindGroupLayout
        const groupEntries = new Map(); // group index -> array of entries

        // Add all uniform group bindings to group 0
        const structEntries = new Map();
        for (const bufferGroup of shader._uniformBufferGroups) {
          const entries = structEntries.get(bufferGroup.group) || [];
          entries.push({
            bufferGroup,
            binding: bufferGroup.binding,
            visibility: shader.shaderType === 'compute'
              ? GPUShaderStage.COMPUTE
              : GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT,
            buffer: { type: 'uniform', hasDynamicOffset: bufferGroup.dynamic },
          });
          structEntries.set(bufferGroup.group, entries);
        }
        for (const [group, entries] of structEntries.entries()) {
          entries.sort((a, b) => a.binding - b.binding);
          groupEntries.set(group, entries);
        }

        // Add the variable amount of samplers and texture bindings that can come after
        for (const sampler of shader.samplers) {
          const group = sampler.group;
          const entries = groupEntries.get(group) || [];
          if (!['sampler', 'texture_2d<f32>'].includes(sampler.type)) {
            throw new Error(`Unsupported texture type: ${sampler.type}`);
          }

          entries.push({
            binding: sampler.binding,
            visibility: sampler.visibility,
            sampler: sampler.type === 'sampler'
              ? { type: 'filtering' }
              : undefined,
            texture: sampler.type === 'texture_2d<f32>'
              ? { sampleType: 'float', viewDimension: '2d' }
              : undefined,
            uniform: sampler,
          });

          entries.sort((a, b) => a.binding - b.binding);
          groupEntries.set(group, entries);
        }

        // Add storage buffer bindings
        for (const storage of shader._storageBuffers || []) {
          const group = storage.group;
          const entries = groupEntries.get(group) || [];

          entries.push({
            binding: storage.binding,
            visibility: storage.visibility,
            buffer: {
              type: storage.accessMode === 'read' ? 'read-only-storage' : 'storage'
            },
            storage: storage,
          });

          entries.sort((a, b) => a.binding - b.binding);
          groupEntries.set(group, entries);
        }

        // Create layouts and bind groups
        const groupEntriesArr = [];
        for (const [group, entries] of groupEntries) {
          const layout = this.device.createBindGroupLayout({ entries });
          bindGroupLayouts.set(group, layout);
          groupEntriesArr.push([group, entries]);
        }

        shader._groupEntries = groupEntriesArr;
        shader._bindGroupLayouts = [...bindGroupLayouts.values()];
        // Reuse bind groups if they don't change
        shader._cachedBindGroup = {};
        // Remember which dynamic buffer we last used, so that we can
        // possibly cache bind groups if unchanged
        shader._lastDynamicBuffer = {};
        shader._pipelineLayout = this.device.createPipelineLayout({
          bindGroupLayouts: shader._bindGroupLayouts,
        });
        shader._compiled = true;
      }

      _getBlendState(mode) {
        switch (mode) {
          case BLEND:
            return {
              color: {
                operation: 'add',
                srcFactor: 'one',
                dstFactor: 'one-minus-src-alpha'
              },
              alpha: {
                operation: 'add',
                srcFactor: 'one',
                dstFactor: 'one-minus-src-alpha'
              }
            };

          case ADD:
            return {
              color: {
                operation: 'add',
                srcFactor: 'one',
                dstFactor: 'one'
              },
              alpha: {
                operation: 'add',
                srcFactor: 'one',
                dstFactor: 'one'
              }
            };

          case REMOVE:
            return {
              color: {
                operation: 'add',
                srcFactor: 'zero',
                dstFactor: 'one-minus-src-alpha'
              },
              alpha: {
                operation: 'add',
                srcFactor: 'zero',
                dstFactor: 'one-minus-src-alpha'
              }
            };

          case MULTIPLY:
            return {
              color: {
                operation: 'add',
                srcFactor: 'dst-color',
                dstFactor: 'one-minus-src-alpha'
              },
              alpha: {
                operation: 'add',
                srcFactor: 'dst-alpha',
                dstFactor: 'one-minus-src-alpha'
              }
            };

          case SCREEN:
            return {
              color: {
                operation: 'add',
                srcFactor: 'one',
                dstFactor: 'one-minus-src-color'
              },
              alpha: {
                operation: 'add',
                srcFactor: 'one',
                dstFactor: 'one-minus-src-alpha'
              }
            };

          case EXCLUSION:
            return {
              color: {
                operation: 'add',
                srcFactor: 'one-minus-dst-color',
                dstFactor: 'one-minus-src-color'
              },
              alpha: {
                operation: 'add',
                srcFactor: 'one',
                dstFactor: 'one'
              }
            };

          case REPLACE:
            return {
              color: {
                operation: 'add',
                srcFactor: 'one',
                dstFactor: 'zero'
              },
              alpha: {
                operation: 'add',
                srcFactor: 'one',
                dstFactor: 'zero'
              }
            };

          case SUBTRACT:
            return {
              color: {
                operation: 'reverse-subtract',
                srcFactor: 'one',
                dstFactor: 'one'
              },
              alpha: {
                operation: 'add',
                srcFactor: 'one',
                dstFactor: 'one-minus-src-alpha'
              }
            };

          case DARKEST:
            return {
              color: {
                operation: 'min',
                srcFactor: 'one',
                dstFactor: 'one'
              },
              alpha: {
                operation: 'min',
                srcFactor: 'one',
                dstFactor: 'one'
              }
            };

          case LIGHTEST:
            return {
              color: {
                operation: 'max',
                srcFactor: 'one',
                dstFactor: 'one'
              },
              alpha: {
                operation: 'max',
                srcFactor: 'one',
                dstFactor: 'one'
              }
            };

          default:
            console.warn(`Unsupported blend mode: ${mode}`);
            return undefined;
        }
      }

      _applyColorBlend() {}

      _getVertexLayout(shader) {
        const layouts = [];

        for (const attrName in shader.attributes) {
          const attr = shader.attributes[attrName];
          if (!attr || attr.location === -1) continue;
          // Get the vertex buffer info associated with this attribute
          const renderBuffer =
            this.buffers[shader.shaderType].find(buf => buf.attr === attrName) ||
            this.buffers.user.find(buf => buf.attr === attrName);
          if (!renderBuffer) continue;

          const { size } = renderBuffer;
          // Convert from the number of floats (e.g. 3) to a recognized WebGPU
          // format (e.g. "float32x3")
          const format = this._getFormatFromSize(size);

          layouts.push({
            arrayStride: size * 4,
            stepMode: 'vertex',
            attributes: [
              {
                shaderLocation: attr.location,
                offset: 0,
                format,
              },
            ],
          });
        }
        return layouts;
      }

      _getVertexBuffers(shader) {
        if (!shader._vertexBuffers) {
          const buffers = [];

          for (const attrName in shader.attributes) {
            const attr = shader.attributes[attrName];
            if (!attr || attr.location === -1) continue;

            // Get the vertex buffer info associated with this attribute
            const renderBuffer =
              this.buffers[shader.shaderType].find(buf => buf.attr === attrName) ||
              this.buffers.user.find(buf => buf.attr === attrName);
            if (!renderBuffer) continue;

            buffers.push(renderBuffer);
          }
          shader._vertexBuffers = buffers;
        }

        return shader._vertexBuffers;
      }

      _getFormatFromSize(size) {
        switch (size) {
          case 1: return 'float32';
          case 2: return 'float32x2';
          case 3: return 'float32x3';
          case 4: return 'float32x4';
          default: throw new Error(`Unsupported attribute size: ${size}`);
        }
      }

      _useShader(shader, options) {}

      _updateViewport() {
        this._origViewport = {
          width: this.width,
          height: this.height,
        };
        this._viewport = [0, 0, this.width, this.height];
      }

      _createPixelsArray() {
        this.pixels = new Uint8Array(
          this.width * this.pixelDensity() * this.height * this.pixelDensity() * 4
        );
      }

      supportsTriangleFan() {
        return false;
      }

      viewport() {}

      zClipRange() {
        return [0, 1];
      }
      defaultNearScale() {
        return 0.01;
      }
      defaultFarScale() {
        return 100;
      }

      _resetBuffersBeforeDraw() {
        this._finishActiveRenderPass();

        // Set state to PENDING - we'll decide on first draw
        if (this._pInst.frameCount > 0) {
          this._frameState = FRAME_STATE.PENDING;
        }

        // Clear depth buffer but DON'T start any render pass yet
        const activeFramebuffer = this.activeFramebuffer();
        const commandEncoder = this.device.createCommandEncoder();

        const depthTextureView = activeFramebuffer
          ? (activeFramebuffer.aaDepthTexture
              ? activeFramebuffer.aaDepthTextureView
              : activeFramebuffer.depthTextureView)
          : this.depthTextureView;

        if (depthTextureView) {
          const depthAttachment = {
            view: depthTextureView,
            depthClearValue: 1.0,
            depthLoadOp: 'clear',
            depthStoreOp: 'store',
            stencilLoadOp: 'load',
            stencilStoreOp: 'store',
          };
          const renderPassDescriptor = {
            colorAttachments: [],
            depthStencilAttachment: depthAttachment,
          };
          const passEncoder = commandEncoder.beginRenderPass(renderPassDescriptor);
          passEncoder.end();
          this._pendingCommandEncoders.push(commandEncoder.finish());
          this._hasPendingDraws = true;
        }
      }

      /**
       * Promotes the current frame to use mainFramebuffer.
       * Copies current canvas content to mainFramebuffer, then switches to rendering there.
       * @private
       */
      _promoteToFramebuffer() {
        // Already promoted this frame
        if (this._frameState === FRAME_STATE.PROMOTED) {
          return;
        }

        // Already drawing to a custom framebuffer, no promotion needed
        if (this.activeFramebuffer()) {
          return;
        }

        // Flush any pending draws to canvas first
        this.flushDraw();

        // Mark as promoted
        this._frameState = FRAME_STATE.PROMOTED;

        // Get current canvas texture
        const canvasTexture = this.drawingContext.getCurrentTexture();

        // Ensure mainFramebuffer matches canvas size
        if (this.mainFramebuffer.width !== this.width ||
            this.mainFramebuffer.height !== this.height) {
          this.mainFramebuffer.resize(this.width, this.height);
        }

        // Copy canvas textures to mainFramebuffer
        const commandEncoder = this.device.createCommandEncoder();

        // Copy color texture
        commandEncoder.copyTextureToTexture(
          {
            texture: canvasTexture,
            origin: { x: 0, y: 0, z: 0 },
            mipLevel: 0,
          },
          {
            texture: this.mainFramebuffer.colorTexture,
            origin: { x: 0, y: 0, z: 0 },
            mipLevel: 0,
          },
          {
            width: Math.ceil(this.width * this._pixelDensity),
            height: Math.ceil(this.height * this._pixelDensity),
            depthOrArrayLayers: 1,
          }
        );

        // Copy depth texture
        commandEncoder.copyTextureToTexture(
          {
            texture: this.depthTexture,
            origin: { x: 0, y: 0, z: 0 },
            mipLevel: 0,
          },
          {
            texture: this.mainFramebuffer.depthTexture,
            origin: { x: 0, y: 0, z: 0 },
            mipLevel: 0,
          },
          {
            width: Math.ceil(this.width * this._pixelDensity),
            height: Math.ceil(this.height * this._pixelDensity),
            depthOrArrayLayers: 1,
          }
        );

        this._pendingCommandEncoders.push(commandEncoder.finish());
        this._hasPendingDraws = true;

        // We want to make sure the transformation state is the same
        // once we're drawing to the framebuffer, because normally
        // those are reset.
        const savedModelMatrix = this.states.uModelMatrix.copy();
        this.states.uModelMatrix.set(this.states.uModelMatrix.copy());
        this.states.uModelMatrix.reset();
        this.mainFramebuffer.defaultCamera.set(this.states.curCamera);

        this.mainFramebuffer.begin();

        this.states.uModelMatrix.set(savedModelMatrix);
      }

      _promoteToFramebufferWithoutCopy() {
        // Already promoted this frame
        if (this._frameState === FRAME_STATE.PROMOTED) {
          return;
        }

        // Ensure mainFramebuffer matches canvas size
        if (this.mainFramebuffer.width !== this.width ||
            this.mainFramebuffer.height !== this.height) {
          this.mainFramebuffer.resize(this.width, this.height);
        }

        // Mark as promoted WITHOUT copying canvas content
        this._frameState = FRAME_STATE.PROMOTED;

        // Flush any pending draws first
        this.flushDraw();

        // Preserve transformation state
        const savedModelMatrix = this.states.uModelMatrix.copy();
        this.states.uModelMatrix.set(this.states.uModelMatrix.copy());
        this.states.uModelMatrix.reset();
        this.mainFramebuffer.defaultCamera.set(this.states.curCamera);

        // Begin rendering to mainFramebuffer
        this.mainFramebuffer.begin();

        this.states.uModelMatrix.set(savedModelMatrix);
      }

      //////////////////////////////////////////////
      // Geometry buffer pool management
      //////////////////////////////////////////////

      _initializeGeometryBufferPools(geometry) {
        if (geometry._vertexBufferPools) {
          return; // Already initialized
        }

        geometry._vertexBufferPools = {}; // Keyed by buffer type (dst)
        geometry._vertexBuffersInUse = {}; // Keyed by buffer type (dst)
        geometry._vertexBuffersToReturn = {}; // Keyed by buffer type (dst)

        // Register this geometry for pool cleanup
        this._geometriesWithPools.push(geometry);
      }

      _getVertexBufferFromPool(geometry, dst, size) {
        // Initialize pools if needed
        this._initializeGeometryBufferPools(geometry);

        // Get or create pool for this buffer type
        if (!geometry._vertexBufferPools[dst]) {
          geometry._vertexBufferPools[dst] = [];
        }
        if (!geometry._vertexBuffersInUse[dst]) {
          geometry._vertexBuffersInUse[dst] = [];
        }
        if (!geometry._vertexBuffersToReturn[dst]) {
          geometry._vertexBuffersToReturn[dst] = [];
        }

        // Try to get a buffer from the pool
        const pool = geometry._vertexBufferPools[dst];
        if (pool.length > 0) {
          const bufferInfo = pool.pop();
          // Check if buffer is large enough
          if (bufferInfo.buffer.size >= size) {
            geometry._vertexBuffersInUse[dst].push(bufferInfo);
            return bufferInfo;
          } else {
            // Buffer too small, don't destroy immediately as it may still be in use
            // Add to retirement array
            this._retiredBuffers.push(bufferInfo.buffer);
          }
        }

        // No suitable buffer available, create a new one
        const newBuffer = this.device.createBuffer({
          size,
          usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
        });

        const bufferInfo = {
          buffer: newBuffer,
          size,
          // Create a copy of the data array to avoid conflicts when geometry is reset
          dataCopy: null
        };

        geometry._vertexBuffersInUse[dst].push(bufferInfo);
        return bufferInfo;
      }

      _returnVertexBuffersToPool() {
        // Return buffers marked for return back to their pools for all registered geometries
        for (const geometry of this._geometriesWithPools) {
          if (geometry._vertexBuffersToReturn) {
            for (const [dst, buffersToReturn] of Object.entries(geometry._vertexBuffersToReturn)) {
              if (buffersToReturn.length > 0) {
                // Move all buffers from ToReturn back to pool
                const pool = geometry._vertexBufferPools[dst] || [];
                while (buffersToReturn.length > 0) {
                  const bufferInfo = buffersToReturn.pop();
                  // Clear the data copy reference to prevent memory leaks
                  bufferInfo.dataCopy = null;
                  pool.push(bufferInfo);
                }
                geometry._vertexBufferPools[dst] = pool;
              }
            }
          }
        }
      }

      // Called when geometry is reset - mark its buffers for return
      onReset(geometry) {
        this._markGeometryBuffersForReturn(geometry);
      }

      // Mark geometry buffers for return when geometry is reset/freed
      _markGeometryBuffersForReturn(geometry) {
        if (geometry._vertexBuffersInUse && geometry._vertexBuffersToReturn) {
          for (const [dst, buffersInUse] of Object.entries(geometry._vertexBuffersInUse)) {
            if (buffersInUse.length > 0) {
              // Move all buffers from InUse to ToReturn
              const buffersToReturn = geometry._vertexBuffersToReturn[dst] || [];
              while (buffersInUse.length > 0) {
                const bufferInfo = buffersInUse.pop();
                buffersToReturn.push(bufferInfo);
              }
              geometry._vertexBuffersToReturn[dst] = buffersToReturn;
            }
          }
        }
      }

      //////////////////////////////////////////////
      // Uniform buffer pool management
      //////////////////////////////////////////////

      _getUniformBufferFromPool(bufferGroup) {
        // Try to get a buffer from the pool
        if (bufferGroup.bufferPool.length > 0) {
          const bufferInfo = bufferGroup.bufferPool.pop();
          bufferGroup.buffersInUse.add(bufferInfo);
          return bufferInfo;
        }

        // No buffers available, create a new one
        const newBuffer = this.device.createBuffer({
          size: bufferGroup.size,
          usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
        });
        const newData = new Float32Array(bufferGroup.size / 4);
        const newDataView = new DataView(newData.buffer);
        const bufferInfo = {
          buffer: newBuffer,
          data: newData,
          dataView: newDataView
        };

        bufferGroup.buffersInUse.add(bufferInfo);
        return bufferInfo;
      }

      _getDynamicUniformBufferFromPool(bufferGroup) {
        //
        let buffer;
        if (
          this.currentUniformBuffer &&
          this.currentUniformBuffer.offset + bufferGroup.size < this.currentUniformBuffer.size
        ) {
          // We can fit this next block of uniforms into the current active memory chunk
          buffer = this.currentUniformBuffer;
        } else if (this.uniformBufferPool.length > 0) {
          buffer = this.uniformBufferPool.pop();
          this.activeUniformBuffers.push(buffer);
        } else {
          // Kinda arbitrary. Each dynamic offset has to be in groups of 256, but then
          // we can choose how many things we want to be able to fit into a block.
          // There's some overhead to each block so if we're drawing a lot of stuff,
          // bigger is better. But it's also a lot of wasted memory if we AREN'T drawing
          // a lot of stuff. So.... right now it's 40. Feel free to update this if
          // a better balance can be achieved.
          const size = 256 * 40;
          buffer = {
            dynamic: true,
            lastOffset: 0,
            offset: 0,
            size,
            buffer: this.device.createBuffer({
              size,
              usage: GPUBufferUsage.MAP_WRITE | GPUBufferUsage.COPY_SRC,
              mappedAtCreation: true,
            }),
            uniformBuffer: this.device.createBuffer({
              size,
              usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
            }),
          };

          buffer.data = new Float32Array(buffer.buffer.getMappedRange());
          buffer.dataView = new DataView(buffer.data.buffer);

          this.activeUniformBuffers.push(buffer);
        }

        this.currentUniformBuffer = buffer;

        return buffer;
      }

      _returnUniformBuffersToPool() {
        // Return all used buffers back to their pools for all registered shaders
        for (const shader of this._shadersWithPools) {
          this._returnShaderBuffersToPool(shader);
        }
      }

      _returnShaderBuffersToPool(shader) {
        if (shader._uniformBufferGroups) {
          for (const bufferGroup of shader._uniformBufferGroups) {
            while (bufferGroup.nextBufferPool.length > 0) {
              bufferGroup.bufferPool.push(bufferGroup.nextBufferPool.pop());
            }
            for (const bufferInfo of bufferGroup.buffersInUse.keys()) {
              if (bufferInfo !== bufferGroup.currentBuffer) {
                bufferGroup.nextBufferPool.push(bufferInfo);
              }
            }
            bufferGroup.buffersInUse.clear();
            if (bufferGroup.currentBuffer) {
              bufferGroup.buffersInUse.add(bufferGroup.currentBuffer);
            }
          }
        }
      }

      flushDraw() {
        this._finishActiveRenderPass();
        // Only submit if we actually had any draws
        if (this._hasPendingDraws) {
          // Create a copy of pending command encoders
          const commandsToSubmit = this._pendingCommandEncoders;
          this._pendingCommandEncoders = [];
          this._hasPendingDraws = false;

          if (this.activeUniformBuffers.length > 0) {
            const encoder = this.device.createCommandEncoder();
            for (const bufferInfo of this.activeUniformBuffers) {
              bufferInfo.buffer.unmap();
              encoder.copyBufferToBuffer(
                bufferInfo.buffer,
                bufferInfo.uniformBuffer,
              );
            }
            commandsToSubmit.unshift(encoder.finish());
          }

          // Submit the commands
          this.queue.submit(commandsToSubmit);

          for (const buf of this.activeUniformBuffers) {
            // buf.buffer = this.device.createBuffer({
              // size: buf.size,
              // usage: GPUBufferUsage.MAP_WRITE | GPUBufferUsage.COPY_SRC,
              // mappedAtCreation: true,
            // });
            buf.offset = 0;
            buf.lastOffset = 0;
            // this.resettingUniformBuffers.push(
              buf.buffer.mapAsync(GPUMapMode.WRITE).then(() => {
                buf.data = new Float32Array(buf.buffer.getMappedRange());
                buf.dataView = new DataView(buf.data.buffer);
                this.uniformBufferPool.push(buf);
                return buf;
              });
            // )
          }
          this.activeUniformBuffers = [];
          this.currentUniformBuffer = undefined;

          // Execute post-submit callbacks after GPU work completes
          if (this._postSubmitCallbacks.length > 0) {
            const callbacks = this._postSubmitCallbacks;
            this._postSubmitCallbacks = [];
            this.device.queue.onSubmittedWorkDone().then(() => {
              for (const callback of callbacks) {
                callback();
              }
            });
          }

          // Reset canvas texture cache for next frame
          this.currentCanvasColorTexture = null;
          this.currentCanvasColorTextureView = null;
        }
      }

      _ensurePixelReadCanvas(width, height) {
        // Create canvas if it doesn't exist
        if (!this._pixelReadCanvas) {
          this._pixelReadCanvas = document.createElement('canvas');
          this._pixelReadCtx = this._pixelReadCanvas.getContext('2d');
        }

        // Resize canvas if dimensions changed
        if (this._pixelReadCanvas.width !== width || this._pixelReadCanvas.height !== height) {
          this._pixelReadCanvas.width = width;
          this._pixelReadCanvas.height = height;
        }

        return { canvas: this._pixelReadCanvas, ctx: this._pixelReadCtx };
      }

      resize(w, h) {
        super.resize(w, h);
        this._hasPendingDraws = true;
        this.flushDraw();
      }

      async finishDraw() {
        this.flushDraw();

        const states = [];

        // Only blit if we promoted to framebuffer this frame
        if (this._frameState === FRAME_STATE.PROMOTED) {
          while (this.activeFramebuffers.length > 0) {
            const fbo = this.activeFramebuffers.pop();
            states.unshift({ fbo, diff: { ...this.states } });
          }
          this.flushDraw();

          this._pInst.push();
          this.states.setValue('enableLighting', false);
          this.states.setValue('activeImageLight', null);
          this._pInst.setCamera(this.finalCamera);
          this._pInst.shader(this._getBlitShader());
          this._pInst.resetMatrix();
          this._pInst.imageMode(this._pInst.CENTER);
          this._pInst.image(this.mainFramebuffer, 0, 0);
          this._pInst.pop();
          this.flushDraw();
        }

        // Return all uniform buffers to their pools
        this._returnUniformBuffersToPool();

        // Mark all geometry buffers for return after frame is complete
        for (const geometry of this._geometriesWithPools) {
          this._markGeometryBuffersForReturn(geometry);
        }

        // this.uniformBufferPool.push(...(await Promise.all(this.resettingUniformBuffers)));
        this.resettingUniformBuffers = [];

        // Return all vertex buffers to their pools
        this._returnVertexBuffersToPool();

        // Destroy all retired buffers
        const retired = this._retiredBuffers;
        this._postSubmitCallbacks.push(() => {
          for (const buffer of retired) {
            if (buffer && buffer.destroy) {
              buffer.destroy();
            }
          }
        });
        this._retiredBuffers = [];

        if (this._frameState === FRAME_STATE.PROMOTED) {
          for (const { fbo, diff } of states) {
            if (fbo !== this.mainFramebuffer || this._frameState !== FRAME_STATE.PROMOTED) {
              fbo.begin();
            }
            for (const key in diff) {
              this.states.setValue(key, diff[key]);
            }
          }
        }
      }

      //////////////////////////////////////////////
      // Rendering
      //////////////////////////////////////////////

      _drawBuffers(geometry, { mode = TRIANGLES, count = 1 }) {
        const buffers = this.geometryBufferCache.getCached(geometry);
        if (!buffers) return;

        // If PENDING and no custom framebuffer, regular draw means PROMOTE
        if (this._frameState === FRAME_STATE.PENDING && !this.activeFramebuffer()) {
          this._promoteToFramebufferWithoutCopy();
        }

        this._beginActiveRenderPass();
        const passEncoder = this.activeRenderPass;

        const currentShader = this._curShader;
        this.setupShaderBindGroups(currentShader, passEncoder, { mode, buffers });
        // Bind vertex buffers
        for (const buffer of currentShader._vertexBuffers || this._getVertexBuffers(currentShader)) {
          const location = currentShader.attributes[buffer.attr].location;
          const gpuBuffer = buffers[buffer.dst];
          passEncoder.setVertexBuffer(location, gpuBuffer, 0);
        }

        if (currentShader.shaderType === "fill") {
          // Bind index buffer and issue draw
          if (buffers.indexBuffer) {
            const indexFormat = buffers.indexFormat || "uint16";
            passEncoder.setIndexBuffer(buffers.indexBuffer, indexFormat);
            passEncoder.drawIndexed(geometry.faces.length * 3, count, 0, 0, 0);
          } else {
            passEncoder.draw(geometry.vertices.length, count, 0, 0);
          }
        } else if (currentShader.shaderType === "text") {
          if (!buffers.indexBuffer) {
            throw new Error("Text geometry must have an index buffer");
          }
          const indexFormat = buffers.indexFormat || "uint16";
          passEncoder.setIndexBuffer(buffers.indexBuffer, indexFormat);
          passEncoder.drawIndexed(geometry.faces.length * 3, count, 0, 0, 0);
        }

        if (buffers.lineVerticesBuffer && currentShader.shaderType === "stroke") {
          passEncoder.draw(geometry.lineVertices.length / 3, count, 0, 0);
        }

        // Mark that we have pending draws that need submission
        this._hasPendingDraws = true;
      }

      setupShaderBindGroups(currentShader, passEncoder, shaderOptionsParams) {
        const shaderOptions = this._shaderOptions(shaderOptionsParams);
        if (
          shaderOptions.compute ||
          this.activeShader !== currentShader ||
          this._shaderOptionsDifferent(shaderOptions)
        ) {
          passEncoder.setPipeline(currentShader.getPipeline(shaderOptions));
        }
        if (!shaderOptions.compute) {
          this.activeShader = currentShader;
          this.activeShaderOptions = shaderOptions;

          // Set stencil reference value for clipping
          const drawTarget = this.drawTarget();
          if (drawTarget._isClipApplied && !this._clipping) {
            // When using the clip mask, test against reference value 0 (background)
            // WebGL uses NOTEQUAL with ref 0, so fragments pass where stencil != 0
            // In WebGPU with 'not-equal', we need ref 0 to pass where stencil != 0
            passEncoder.setStencilReference(0);
          } else if (this._clipping) {
            // When writing to the clip mask, write reference value 1
            passEncoder.setStencilReference(1);
          }
        }

        for (const bufferGroup of currentShader._uniformBufferGroups) {
          if (bufferGroup.dynamic) {
            // Bind uniforms into a part of a big dynamic memory block because
            // the group changes often
            const uniformBufferInfo = this._getDynamicUniformBufferFromPool(bufferGroup);
            if (currentShader._lastDynamicBuffer[bufferGroup.cacheKey] !== uniformBufferInfo) {
              currentShader._cachedBindGroup[bufferGroup.group] = undefined;
              currentShader._lastDynamicBuffer[bufferGroup.cacheKey] = uniformBufferInfo;
            }
            this._packUniformGroup(currentShader, bufferGroup.uniforms, uniformBufferInfo);
            uniformBufferInfo.lastOffset = uniformBufferInfo.offset;
            uniformBufferInfo.offset += Math.ceil(bufferGroup.size / this.uniformBufferAlignment) * this.uniformBufferAlignment;

            // Make a shallow copy so that we keep track of the last offset for this uniform
            bufferGroup.currentDynamicBuffer = uniformBufferInfo;
            bufferGroup.lastOffset = uniformBufferInfo.lastOffset;
          } else {
            // Bind uniforms to a binding-specific buffer, which may be cached for performance
            let bufferInfo;
            const dataChanged = this._hasGroupDataChanged(currentShader, bufferGroup);

            if (!dataChanged && bufferGroup.currentBuffer) {
              // Reuse the cached buffer - no need to pack or write
              bufferInfo = bufferGroup.currentBuffer;
              bufferGroup.buffersInUse.add(bufferInfo);
            } else {
              // Data changed - get a new buffer and write to it
              bufferInfo = this._getUniformBufferFromPool(bufferGroup);
              this._packUniformGroup(currentShader, bufferGroup.uniforms, bufferInfo);
              this.device.queue.writeBuffer(
                bufferInfo.buffer,
                0,
                bufferInfo.data.buffer,
                bufferInfo.data.byteOffset,
                bufferInfo.data.byteLength
              );

              currentShader.buffersDirty.delete(bufferGroup.group * 1000 + bufferGroup.binding);
              currentShader._cachedBindGroup[bufferGroup.group] = undefined;

              // Cache this buffer and data for next frame
              bufferGroup.currentBuffer = bufferInfo;
            }
          }
        }
        for (const sampler of currentShader.samplers) {
          const key = sampler.group * 1000 + sampler.binding;
          if (currentShader.buffersDirty.has(key)) {
            currentShader._cachedBindGroup[sampler.group] = undefined;
            currentShader.buffersDirty.delete(key);
          }
        }
        for (const storage of currentShader._storageBuffers || []) {
          const key = storage.group * 1000 + storage.binding;
          if (currentShader.buffersDirty.has(key)) {
            currentShader._cachedBindGroup[storage.group] = undefined;
            currentShader.buffersDirty.delete(key);
          }
        }

        // Bind sampler/texture uniforms and uniform buffers
        for (const iter of currentShader._groupEntries) {
          const group = iter[0];
          const entries = iter[1];
          let dynamicOffsetIdx = 0;
          const bgEntries = [];
          let bindGroup = currentShader._cachedBindGroup[group];
          for (const entry of entries) {
            const bufferGroup = entry.bufferGroup;
            // Check if this is a uniform buffer binding
            const uniformBufferInfo =
              bufferGroup?.currentBuffer || bufferGroup?.currentDynamicBuffer;
            if (uniformBufferInfo) {
              if (bufferGroup.dynamic) {
                this.dynamicEntryOffsets[dynamicOffsetIdx++] = bufferGroup.lastOffset;
              }
              if (!bindGroup) {
                bgEntries.push({
                  binding: entry.binding,
                  resource: bufferGroup.dynamic
                    ? {
                      buffer: uniformBufferInfo.uniformBuffer,
                      offset: 0,
                      size: Math.ceil(bufferGroup.size / this.uniformBufferAlignment) * this.uniformBufferAlignment,
                    }
                    : { buffer: uniformBufferInfo.buffer },
                });
              }
            } else if (entry.storage && !bindGroup) {
              // Storage buffer binding
              const uniform = currentShader.uniforms[entry.storage.name];
              if (!uniform || !uniform._cachedData || !uniform._cachedData._isStorageBuffer) {
                throw new Error(
                  `Storage buffer "${entry.storage.name}" not set. ` +
                  `Use shader.setUniform("${entry.storage.name}", storageBuffer)`
                );
              }
              bgEntries.push({
                binding: entry.binding,
                resource: { buffer: uniform._cachedData.buffer },
              });
            } else if (!bindGroup) {
              bgEntries.push({
                binding: entry.binding,
                resource: entry.uniform.type === 'sampler'
                  ? (entry.uniform.textureSource.texture || this._getEmptyTexture()).getSampler()
                  : (entry.uniform.texture || this._getEmptyTexture()).textureHandle.view,
              });
            }
          }

          const layout = currentShader._bindGroupLayouts[group];
          if (!bindGroup) {
            bindGroup = this.device.createBindGroup({
              layout,
              entries: bgEntries,
            });
          }
          currentShader._cachedBindGroup[group] = bindGroup;
          if (dynamicOffsetIdx === 0) {
            passEncoder.setBindGroup(
              group,
              bindGroup,
            );
          } else {
            passEncoder.setBindGroup(
              group,
              bindGroup,
              this.dynamicEntryOffsets,
              0,
              dynamicOffsetIdx
            );
          }
        }
        return passEncoder;
      }

      //////////////////////////////////////////////
      // SHADER
      //////////////////////////////////////////////

      // Writes a single field's value into a Float32Array+DataView at (baseOffset + field.offset).
      //
      // Field interface (shared by uniform fields from _parseStruct and struct storage schema fields):
      //   baseType:    string  - 'f32', 'i32', 'u32', etc.
      //   size:        number  - byte size of the field
      //   offset:      number  - byte offset of the field within its struct
      //   packInPlace: bool    - true for mat3, written with manual column padding
      //
      // value: number or number[] - the data to write
      _packField(field, value, floatView, dataView, baseOffset) {
        if (value === undefined) return;

        // Duck typing instead of instanceof to avoid importing a separate
        // copy of the Color/Vector classes
        if (value?.isVector) {
          value = value.values.length !== value.dimensions ? value.values.slice(0, value.dimensions) : value.values;
        } else if (value?.isColor) {
          value = value._getRGBA([1, 1, 1, 1]);
        }
        const byteOffset = baseOffset + field.offset;
        if (field.baseType === 'u32') {
          if (field.size === 4) {
            dataView.setUint32(byteOffset, value, true);
          } else {
            for (let i = 0; i < value.length; i++) {
              dataView.setUint32(byteOffset + i * 4, value[i], true);
            }
          }
        } else if (field.baseType === 'i32') {
          if (field.size === 4) {
            dataView.setInt32(byteOffset, value, true);
          } else {
            for (let i = 0; i < value.length; i++) {
              dataView.setInt32(byteOffset + i * 4, value[i], true);
            }
          }
        } else if (field.packInPlace) {
          // In-place packing for mat3: write directly to buffer with padding
          const base = byteOffset / 4;
          floatView[base + 0] = value[0]; floatView[base + 1] = value[1]; floatView[base + 2] = value[2];
          floatView[base + 4] = value[3]; floatView[base + 5] = value[4]; floatView[base + 6] = value[5];
          floatView[base + 8] = value[6]; floatView[base + 9] = value[7]; floatView[base + 10] = value[8];
        } else if (field.size === 4) {
          floatView.set([value], byteOffset / 4);
        } else {
          floatView.set(value, byteOffset / 4);
        }
      }

      _packUniformGroup(shader, groupUniforms, bufferInfo) {
        // Pack a single group's uniforms into a buffer
        const data = bufferInfo.data;
        const dataView = bufferInfo.dataView;
        const offset = bufferInfo.offset || 0;
        for (const uniform of groupUniforms) {
          const fullUniform = shader.uniforms[uniform.name];
          if (!fullUniform || fullUniform.isSampler) continue;
          this._packField(fullUniform, fullUniform._mappedData, data, dataView, offset);
        }
      }

      _hasGroupDataChanged(shader, bufferGroup) {
        // First time
        if (!bufferGroup.currentBuffer) return true;
        return shader.buffersDirty.has(bufferGroup.group * 1000 + bufferGroup.binding);
      }

      _parseStruct(shaderSource, structName) {
        const structMatch = shaderSource.match(
          new RegExp(`struct\\s+${structName}\\s*\\{([^\\}]+)\\}`)
        );
        if (!structMatch) {
          throw new Error(`Can't find a struct definition for ${structName}`);
        }

        const structBody = structMatch[1];
        const elements = {};
        let match;
        let index = 0;
        let offset = 0;

        const elementRegex =
          /(?:@location\((\d+)\)\s+)?(\w+):\s*([^\n]+?),?\n/g;

        const baseAlignAndSize = (type) => {
          if (['f32', 'i32', 'u32', 'bool'].includes(type)) {
            return { align: 4, size: 4, items: 1, baseType: type };
          }
          if (/^vec[2-4](<f32>|f)$/.test(type)) {
            const n = parseInt(type.match(/^vec([2-4])/)[1]);
            const size = 4 * n;
            const align = n === 2 ? 8 : 16;
            return { align, size, items: n, baseType: 'f32' };
          }
          if (/^vec[2-4]<(i32|u32)>$/.test(type)) {
            const n = parseInt(type.match(/^vec([2-4])/)[1]);
            const match = type.match(/^vec[2-4]<(i32|u32)>$/);
            const baseType = match[1]; // 'i32' or 'u32'
            const size = 4 * n;
            const align = n === 2 ? 8 : 16;
            return { align, size, items: n, baseType };
          }
          if (/^mat[2-4](?:x[2-4])?(<f32>|f)$/.test(type)) {
            if (type[4] === 'x' && type[3] !== type[5]) {
              throw new Error('Non-square matrices not implemented yet');
            }
            const dim = parseInt(type[3]);
            const align = dim === 2 ? 8 : 16;
            // Each column must be aligned
            const size = Math.ceil(dim * 4 / align) * align * dim;
            // For mat3, use in-place packing to avoid array allocation
            const pack = dim === 3
              ? (data) => [
                ...data.slice(0, 3),
                ...data.slice(3, 6),
                ...data.slice(6, 9),
              ]
              : undefined;
            const packInPlace = dim === 3;
            return { align, size, pack, packInPlace, items: dim * dim, baseType: 'f32' };
          }
          if (/^array<.+>$/.test(type)) {
            const [, subtype, rawLength] = type.match(/^array<(.+),\s*(\d+)>/);
            const length = parseInt(rawLength);
            const {
              align: elemAlign,
              size: elemSize,
              items: elemItems,
              pack: elemPack = (data) => [...data],
              baseType: elemBaseType
            } = baseAlignAndSize(subtype);
            const stride = Math.ceil(elemSize / elemAlign) * elemAlign;
            const pack = (data) => {
              const result = [];
              for (let i = 0; i < data.length; i += elemItems) {
                const elemData = elemPack(data.slice(i, elemItems));
                result.push(...elemData);
                for (let j = 0; j < stride / 4 - elemData.length; j++) {
                  result.push(0);
                }
              }
              return result;
            };
            return {
              align: elemAlign,
              size: stride * length,
              items: elemItems * length,
              pack,
              baseType: elemBaseType
            };
          }
          throw new Error(`Unknown type in WGSL struct: ${type}`);
        };

        while ((match = elementRegex.exec(structBody)) !== null) {
          const [_, location, name, type] = match;
          const { size, align, pack, packInPlace, baseType } = baseAlignAndSize(type);
          offset = Math.ceil(offset / align) * align;
          const offsetEnd = offset + size;
          elements[name] = {
            name,
            location: location ? parseInt(location) : undefined,
            index,
            type,
            size,
            offset,
            offsetEnd,
            pack,
            packInPlace,
            baseType
          };
          index++;
          offset = offsetEnd;
        }

        return elements;
      }

      _mapUniformData(uniform, data) {
        if (uniform.pack) {
          return uniform.pack(data);
        }
        return data;
      }

      _getShaderAttributes(shader) {
        const mainMatch = /fn main\(.+:\s*([^\s\)]+)/.exec(shader._vertSrc);
        if (!mainMatch) throw new Error("Can't find `fn main` in vertex shader source");
        const inputType = mainMatch[1];

        return this._parseStruct(shader.vertSrc(), inputType);
      }

      getUniformMetadata(shader) {
        // Parse all uniform struct bindings in group 0.
        // TODO: support non-sampler uniforms being in other groups

        // Each binding represents a logical group of uniforms, since they get
        // updated or cached all at once.

        const uniformGroups = [];
        const uniformVarRegex = /@group\((\d+)\)\s+@binding\((\d+)\)\s+var<uniform>\s+(\w+)\s*:\s*(\w+);/g;

        let match;
        const src = shader.shaderType === 'compute' ? shader.computeSrc() : shader.vertSrc();
        while ((match = uniformVarRegex.exec(src)) !== null) {
          const [_, groupNum, binding, varName, structType] = match;
          const bindingIndex = parseInt(binding);
          const uniforms = this._parseStruct(src, structType);

          uniformGroups.push({
            group: parseInt(groupNum),
            binding: bindingIndex,
            varName,
            structType,
            uniforms
          });
        }

        if (uniformGroups.length === 0 && shader.shaderType !== 'compute') {
          throw new Error('Expected at least one uniform struct bound to @group(0)');
        }

        // While we're also keeping track of the groups, the API we expose
        // to users of p5 is just a flat list of uniforms (which can be the
        // individual struct items in the group.)
        const allUniforms = {};
        for (const group of uniformGroups) {
          for (const [uniformName, uniformData] of Object.entries(group.uniforms)) {
            allUniforms[uniformName] = {
              ...uniformData,
              group: group.group,
              binding: group.binding,
              varName: group.varName
            };
          }
        }

        // Store uniform groups for buffer pooling
        shader._uniformGroups = uniformGroups;

        // Extract samplers from group bindings
        const samplers = {};
        // TODO: support other texture types
        const samplerRegex = /@group\((\d+)\)\s*@binding\((\d+)\)\s*var\s+(\w+)\s*:\s*(texture_2d<f32>|sampler);/g;

        // Extract storage buffers
        const storageBuffers = {};
        const storageRegex = /@group\((\d+)\)\s*@binding\((\d+)\)\s*var<storage,\s*(read|read_write)>\s+(\w+)\s*:\s*array<\w+>/g;

        // Track which bindings are taken by the struct properties we've parsed
        // (the rest should be textures/samplers)
        const structUniformBindings = {};
        for (const g of uniformGroups) {
          structUniformBindings[g.group + ',' + g.binding] = true;
        }

        for (const [src, visibility] of [
          [shader.vertSrc(), GPUShaderStage.VERTEX],
          [shader.fragSrc(), GPUShaderStage.FRAGMENT],
          [shader.computeSrc ? shader.computeSrc() : null, GPUShaderStage.COMPUTE]
        ]) {
          if (!src) continue; // Skip if shader stage doesn't exist

          let match;
          while ((match = samplerRegex.exec(src)) !== null) {
            const [_, group, binding, name, type] = match;
            const groupIndex = parseInt(group);
            const bindingIndex = parseInt(binding);
            // Skip struct uniform bindings which we've already parsed
            if (structUniformBindings[groupIndex + ',' + bindingIndex]) continue;

            const key = `${groupIndex},${bindingIndex}`;
            samplers[key] = {
              visibility: (samplers[key]?.visibility || 0) | visibility,
              group: groupIndex,
              binding: bindingIndex,
              name,
              type,
              isSampler: true,
              noData: type === 'sampler',
            };
          }

          for (const sampler of Object.values(samplers)) {
            if (sampler.type.startsWith('texture')) {
              const samplerName = sampler.name + '_sampler';
              const samplerNode = Object
                .values(samplers)
                .find((s) => s.name === samplerName);
              if (!samplerNode) {
                throw new Error(
                  `Every shader texture needs an accompanying sampler. Could not find sampler ${samplerName} for texture ${sampler.name}`
                );
              }
              samplerNode.textureSource = sampler;
            }
          }

          // Parse storage buffers
          while ((match = storageRegex.exec(src)) !== null) {
            const [_, group, binding, accessMode, name] = match;
            const groupIndex = parseInt(group);
            const bindingIndex = parseInt(binding);

            const key = `${groupIndex},${bindingIndex}`;
            const existing = storageBuffers[key];
            // If any stage uses read_write, the bind group layout must use read_write
            const finalAccessMode = (existing?.accessMode === 'read_write' || accessMode === 'read_write')
              ? 'read_write'
              : accessMode;

            storageBuffers[key] = {
              visibility: (existing?.visibility || 0) | visibility,
              group: groupIndex,
              binding: bindingIndex,
              name,
              accessMode: finalAccessMode, // 'read' or 'read_write'
              isStorage: true,
              type: 'storage'
            };
          }
        }

        // Store storage buffers on shader for later use
        shader._storageBuffers = Object.values(storageBuffers);

        return [...Object.values(allUniforms).sort((a, b) => a.index - b.index), ...Object.values(samplers), ...Object.values(storageBuffers)];
      }

      getNextBindingIndex({ vert, frag, compute }, group = 0) {
        // Get the highest binding index in the specified group and return the next available
        const bindingRegex = /@group\((\d+)\)\s*@binding\((\d+)\)/g;
        let maxBindingIndex = -1;

        const sources = [];
        if (vert) sources.push([vert, GPUShaderStage.VERTEX]);
        if (frag) sources.push([frag, GPUShaderStage.FRAGMENT]);
        if (compute) sources.push([compute, GPUShaderStage.COMPUTE]);

        for (const [src, visibility] of sources) {
          let match;
          while ((match = bindingRegex.exec(src)) !== null) {
            const [_, groupIndex, bindingIndex] = match;
            if (parseInt(groupIndex) === group) {
              maxBindingIndex = Math.max(maxBindingIndex, parseInt(bindingIndex));
            }
          }
        }

        return maxBindingIndex + 1;
      }

      updateUniformValue(shader, uniform, data) {
        if (uniform.isSampler) {
          uniform.texture =
            data instanceof Texture ? data : this.getTexture(data);
        } else if (!data?._isStorageBuffer) {
          uniform._mappedData = this._mapUniformData(uniform, uniform._cachedData);
        }
        shader.buffersDirty.add(uniform.group * 1000 + uniform.binding);
      }

      _updateTexture(uniform, tex) {
        tex.update();
      }

      bindTexture(tex) {}
      unbindTexture(tex) {}
      _unbindFramebufferTexture(uniform) {}

      createTexture({ width, height, format = 'rgba8unorm', usage }) {
        const gpuTexture = this.device.createTexture({
          size: [width, height],
          format,
          usage: usage || (
            GPUTextureUsage.TEXTURE_BINDING |
            GPUTextureUsage.COPY_DST |
            GPUTextureUsage.RENDER_ATTACHMENT
          ),
        });
        return { gpuTexture, view: gpuTexture.createView() };
      }

      uploadTextureFromSource({ gpuTexture }, source) {
        this.queue.copyExternalImageToTexture(
          { source },
          { texture: gpuTexture },
          [source.width, source.height]
        );

        // Force submission to ensure texture upload completes before usage
        this._hasPendingDraws = true;
        this.flushDraw();
      }

      uploadTextureFromData({ gpuTexture }, data, width, height) {
        this.queue.writeTexture(
          { texture: gpuTexture },
          data,
          { bytesPerRow: width * 4, rowsPerImage: height },
          { width, height, depthOrArrayLayers: 1 }
        );

        // Force submission to ensure texture upload completes before usage
        this._hasPendingDraws = true;
        this.flushDraw();
      }

      setTextureParams(_texture) {}

      getSampler(texture) {
        const key = `${texture.minFilter}_${texture.magFilter}_${texture.wrapS}_${texture.wrapT}`;
        if (this.samplers.has(key)) {
          return this.samplers.get(key);
        }
        const constantMapping = {
          [NEAREST]: 'nearest',
          [LINEAR]: 'linear',
          [CLAMP]: 'clamp-to-edge',
          [REPEAT]: 'repeat',
          [MIRROR]: 'mirror-repeat'
        };
        const sampler = this.device.createSampler({
          magFilter: constantMapping[texture.magFilter],
          minFilter: constantMapping[texture.minFilter],
          addressModeU: constantMapping[texture.wrapS],
          addressModeV: constantMapping[texture.wrapT],
        });
        this.samplers.set(key, sampler);
        return sampler;
      }

      bindTextureToShader(_texture, _sampler, _uniformName, _unit) {}

      deleteTexture({ gpuTexture }) {
        this._postSubmitCallbacks.push(() => gpuTexture.destroy());
      }

      _getLightShader() {
        if (!this._defaultLightShader) {
          this._defaultLightShader = new Shader(
            this,
            materialVertexShader,
            materialFragmentShader,
            {
              vertex: {
                "void beforeVertex": "() {}",
                "Vertex getObjectInputs": "(inputs: Vertex) { return inputs; }",
                "Vertex getWorldInputs": "(inputs: Vertex) { return inputs; }",
                "Vertex getCameraInputs": "(inputs: Vertex) { return inputs; }",
                "void afterVertex": "() {}",
              },
              fragment: {
                "void beforeFragment": "() {}",
                "Inputs getPixelInputs": "(inputs: Inputs) { return inputs; }",
                "vec4f combineColors": `(components: ColorComponents) {
                var rgb = vec3<f32>(0.0);
                rgb += components.diffuse * components.baseColor;
                rgb += components.ambient * components.ambientColor;
                rgb += components.specular * components.specularColor;
                rgb += components.emissive;
                return vec4<f32>(rgb, components.opacity);
              }`,
                "vec4f getFinalColor": "(color: vec4<f32>, texCoord: vec2<f32>) { return color; }",
                "void afterFragment": "() {}",
              },
            }
          );
        }
        return this._defaultLightShader;
      }

      _getColorShader() {
        if (!this._defaultColorShader) {
          this._defaultColorShader = new Shader(
            this,
            colorVertexShader,
            colorFragmentShader,
            {
              vertex: {
                "void beforeVertex": "() {}",
                "Vertex getObjectInputs": "(inputs: Vertex) { return inputs; }",
                "Vertex getWorldInputs": "(inputs: Vertex) { return inputs; }",
                "Vertex getCameraInputs": "(inputs: Vertex) { return inputs; }",
                "void afterVertex": "() {}",
              },
              fragment: {
                "void beforeFragment": "() {}",
                "vec4<f32> getFinalColor": "(color: vec4<f32>, texCoord: vec2<f32>) { return color; }",
                "void afterFragment": "() {}",
              },
            }
          );
        }
        return this._defaultColorShader;
      }

      _getLineShader() {
        if (!this._defaultLineShader) {
          this._defaultLineShader = new Shader(
            this,
            lineDefs + lineVertexShader,
            lineDefs + lineFragmentShader,
            {
              vertex: {
                "void beforeVertex": "() {}",
                "StrokeVertex getObjectInputs": "(inputs: StrokeVertex) { return inputs; }",
                "StrokeVertex getWorldInputs": "(inputs: StrokeVertex) { return inputs; }",
                "StrokeVertex getCameraInputs": "(inputs: StrokeVertex) { return inputs; }",
                "void afterVertex": "() {}",
              },
              fragment: {
                "void beforeFragment": "() {}",
                "Inputs getPixelInputs": "(inputs: Inputs) { return inputs; }",
                "vec4<f32> getFinalColor": "(color: vec4<f32>, texCoord: vec2<f32>) { return color; }",
                "bool shouldDiscard": "(outside: bool) { return outside; };",
                "void afterFragment": "() {}",
              },
            }
          );
        }
        return this._defaultLineShader;
      }

      _getFontShader() {
        if (!this._defaultFontShader) {
          this._defaultFontShader = new Shader(
            this,
            fontVertexShader,
            fontFragmentShader
          );
        }
        return this._defaultFontShader;
      }

      _getBlitShader() {
        if (!this._defaultBlitShader) {
          this._defaultBlitShader = new Shader(
            this,
            blitVertexShader,
            blitFragmentShader
          );
        }
        return this._defaultBlitShader;
      }

      //////////////////////////////////////////////
      // Setting
      //////////////////////////////////////////////
      _adjustDimensions(width, height) {
        // TODO: find max texture size
        return { adjustedWidth: width, adjustedHeight: height };
      }

      _applyClip() {
        const commandEncoder = this.device.createCommandEncoder();

        const activeFramebuffer = this.activeFramebuffer();
        const depthTextureView = activeFramebuffer
          ? (activeFramebuffer.aaDepthTexture
              ? activeFramebuffer.aaDepthTextureView
              : activeFramebuffer.depthTextureView)
          : this.depthTextureView;

        if (!depthTextureView) {
          return;
        }

        const depthStencilAttachment = {
          view: depthTextureView,
          stencilLoadOp: 'clear',
          stencilStoreOp: 'store',
          stencilClearValue: 0,
          depthReadOnly: true,
          stencilReadOnly: false,
        };

        const renderPassDescriptor = {
          colorAttachments: [],
          depthStencilAttachment: depthStencilAttachment,
        };

        const passEncoder = commandEncoder.beginRenderPass(renderPassDescriptor);
        passEncoder.end();

        this._pendingCommandEncoders.push(commandEncoder.finish());
        this._hasPendingDraws = true;
      }

      _unapplyClip() {
        // In WebGPU, clip unapplication is handled through pipeline state rather than direct commands
        // The stencil test configuration is set in the render pipeline based on _clipping and _clipInvert flags
        // This is already handled in the _shaderOptions() method and pipeline creation
      }

      _clearClipBuffer() {
        this._finishActiveRenderPass();
        const commandEncoder = this.device.createCommandEncoder();

        const activeFramebuffer = this.activeFramebuffer();
        const depthTextureView = activeFramebuffer
          ? (activeFramebuffer.aaDepthTexture
              ? activeFramebuffer.aaDepthTextureView
              : activeFramebuffer.depthTextureView)
          : this.depthTextureView;

        if (!depthTextureView) {
          return;
        }

        const depthStencilAttachment = {
          view: depthTextureView,
          stencilLoadOp: 'clear',
          stencilStoreOp: 'store',
          stencilClearValue: 1,
          depthReadOnly: true,
          stencilReadOnly: false,
        };

        const renderPassDescriptor = {
          colorAttachments: [],
          depthStencilAttachment: depthStencilAttachment,
        };

        const passEncoder = commandEncoder.beginRenderPass(renderPassDescriptor);
        passEncoder.end();

        this._pendingCommandEncoders.push(commandEncoder.finish());
        this._hasPendingDraws = true;
      }

      _applyStencilTestIfClipping() {
        // This is done via pipeline state in WebGL so this is a no-op
      }

      //////////////////////////////////////////////
      // Shader hooks
      //////////////////////////////////////////////
      uniformNameFromHookKey(key) {
        return key.slice(0, key.indexOf(':'));
      }
      populateHooks(shader, src, shaderType) {
        if (!src.includes('fn main')) return src;

        // Apply some p5-specific preprocessing. WGSL doesn't have preprocessor
        // statements, but some of our shaders might need it, so we add a lightweight
        // way to add code if a hook is augmented. e.g.:
        //   struct Uniforms {
        //   // @p5 ifdef Vertex getWorldInputs
        //     uModelMatrix: mat4f,
        //     uViewMatrix: mat4f,
        //   // @p5 endif
        //   // @p5 ifndef Vertex getWorldInputs
        //     uModelViewMatrix: mat4f,
        //   // @p5 endif
        //   }
        src = src.replace(
          /\/\/ @p5 (ifdef|ifndef) (\w+)\s+(\w+)\n((?:(?!\/\/ @p5)(?:.|\n))*)\/\/ @p5 endif/g,
          (_, condition, hookType, hookName, body) => {
            const target = condition === 'ifdef';
            if (
              (
                !!shader.hooks.modified.vertex[`${hookType} ${hookName}`] ||
                !!shader.hooks.modified.fragment[`${hookType} ${hookName}`]
              ) === target
            ) {
              return body;
            } else {
              return '';
            }
          }
        );

        let [preMain, main, postMain] = src.split(/((?:@(?:vertex|fragment|compute)\s*(?:@workgroup_size\([^)]+\)\s*)?)?fn main[^{]+\{)/);

        const getBuiltinParamName = (mainSrc, builtinName) => {
          const match = new RegExp(`@builtin\\s*\\(\\s*${builtinName}\\s*\\)\\s*(\\w+)\\s*:`).exec(mainSrc);
          return match ? match[1] : null;
        };

        const ensureBuiltinParam = (mainSrc, builtinName, fallbackName, typeName) => {
          const existingName = getBuiltinParamName(mainSrc, builtinName);
          if (existingName) {
            return { mainSrc, argName: existingName };
          }

          const hasParams = /\(\s*\S/.test(mainSrc);
          const injectedMain = mainSrc.replace(
            /\)\s*(->|\{)/,
            `${hasParams ? ', ' : ''}@builtin(${builtinName}) ${fallbackName}: ${typeName}) $1`
          );

          return { mainSrc: injectedMain, argName: fallbackName };
        };

        const getMainStructParameter = (mainSrc) => {
          const match = /fn main\s*\(\s*(\w+)\s*:\s*(\w+)/.exec(mainSrc);
          if (!match) return null;
          return { inputName: match[1], structName: match[2] };
        };

        const getStructBuiltinFieldName = (structName, builtinName) => {
          const structMatch = new RegExp(`struct\\s+${structName}\\s*\\{([^}]*)\\}`, 's').exec(preMain);
          if (!structMatch) return null;
          const fieldMatch = new RegExp(`@builtin\\s*\\(\\s*${builtinName}\\s*\\)\\s*(\\w+)\\s*:`, 's').exec(structMatch[1]);
          return fieldMatch ? fieldMatch[1] : null;
        };

        const appendHookParams = (params, additionalParams) => {
          if (additionalParams.length === 0) return params;
          const hasParams = !/^\(\s*\)$/.test(params);
          return `${params.slice(0, -1)}${hasParams ? ', ' : ''}${additionalParams.join(', ')})`;
        };

        let hookExtraParams = [];
        let hookExtraArgs = [];

        if (shaderType === 'vertex') {
          const ensuredInstance = ensureBuiltinParam(main, 'instance_index', 'instanceID', 'u32');
          main = ensuredInstance.mainSrc;

          const ensuredVertex = ensureBuiltinParam(main, 'vertex_index', '_p5VertexId', 'u32');
          main = ensuredVertex.mainSrc;

          hookExtraParams = ['instanceID: u32', '_p5VertexId: u32'];
          hookExtraArgs = [ensuredInstance.argName, ensuredVertex.argName];
        } else if (shaderType === 'fragment') {
          const directPositionArg = getBuiltinParamName(main, 'position');
          let fragmentPositionArg = directPositionArg;

          if (!fragmentPositionArg) {
            const mainStructParam = getMainStructParameter(main);
            if (mainStructParam) {
              const positionField = getStructBuiltinFieldName(mainStructParam.structName, 'position');
              if (positionField) {
                fragmentPositionArg = `${mainStructParam.inputName}.${positionField}`;
              }
            }
          }

          if (!fragmentPositionArg) {
            const ensuredPosition = ensureBuiltinParam(main, 'position', '_p5FragPos', 'vec4<f32>');
            main = ensuredPosition.mainSrc;
            fragmentPositionArg = ensuredPosition.argName;
          }

          hookExtraParams = ['_p5FragPos: vec4<f32>'];
          hookExtraArgs = [fragmentPositionArg];
        } else if (shaderType === 'compute') {
          const ensuredGlobalId = ensureBuiltinParam(main, 'global_invocation_id', '_p5GlobalId', 'vec3<u32>');
          main = ensuredGlobalId.mainSrc;

          hookExtraParams = ['_p5GlobalId: vec3<u32>'];
          hookExtraArgs = [ensuredGlobalId.argName];
        }

        // Inject hook uniforms as a separate struct at a new binding
        let hookUniformFields = '';
        for (const key in shader.hooks.uniforms) {
          // Skip textures, they don't get added to structs
          if (key.endsWith(': sampler2D')) continue;

          // WGSL format: "name: type"
          hookUniformFields += `  ${key},\n`;
        }

        if (hookUniformFields) {
          // Find the next available binding in group 0
          // Use the source we're currently building (preMain) which has texture bindings. We can't call `fragSrc()`
          // or `vertSrc()` because we may be in one of those calls already, and might infinite loop
          const nextBinding = this.getNextBindingIndex({
            vert: shaderType === 'vertex' ? preMain + (shader.hooks.vertex?.declarations ?? '') + shader.hooks.declarations : shader._vertSrc,
            frag: shaderType === 'fragment' ? preMain + (shader.hooks.fragment?.declarations ?? '') + shader.hooks.declarations : shader._fragSrc,
            compute: shaderType === 'compute' ? preMain + (shader.hooks.compute?.declarations ?? '') + shader.hooks.declarations : shader._computeSrc,
          }, 0);

          // Create HookUniforms struct and binding
          const hookUniformsDecl = `
// Hook Uniforms (from .modify())
struct HookUniforms {
${hookUniformFields}}

@group(0) @binding(${nextBinding}) var<uniform> hooks: HookUniforms;
`;
          // Insert before the first @group binding, or at the end if there are none
          const replaced = preMain.replace(/(@group\(0\)\s+@binding)/, `${hookUniformsDecl}\n$1`);
          if (replaced === preMain) {
            // No @group bindings found in base shader, append to preMain
            preMain = preMain + '\n' + hookUniformsDecl;
          } else {
            preMain = replaced;
          }
        }

        // Handle varying variables by injecting them into VertexOutput and FragmentInput structs
        if (shader.hooks.varyingVariables && shader.hooks.varyingVariables.length > 0) {
          // Generate struct members for varying variables
          let nextLocationIndex = this._getNextAvailableLocation(preMain, shaderType);
          let varyingMembers = '';

          for (const varyingVar of shader.hooks.varyingVariables) {
            // varyingVar is a string like "varName: vec3<f32>"
            const member = `@location(${nextLocationIndex++}) ${varyingVar},`;
            varyingMembers += member + '\n';
          }

          if (shaderType === 'vertex') {
            // Inject into VertexOutput struct
            preMain = preMain.replace(
              /struct\s+VertexOutput\s+\{([^}]*)\}/,
              (match, body) => `struct VertexOutput {${body}\n${varyingMembers}}`
            );
          } else if (shaderType === 'fragment') {
            // Inject into FragmentInput struct
            preMain = preMain.replace(
              /struct\s+FragmentInput\s+\{([^}]*)\}/,
              (match, body) => `struct FragmentInput {${body}\n${varyingMembers}}`
            );
          }
        }

        // Add file-global varying variable declarations
        if (shader.hooks.varyingVariables && shader.hooks.varyingVariables.length > 0) {
          let varyingDeclarations = '';
          for (const varyingVar of shader.hooks.varyingVariables) {
            // varyingVar is a string like "varName: vec3<f32>"
            const [varName, varType] = varyingVar.split(':').map(s => s.trim());
            varyingDeclarations += `var<private> ${varName}: ${varType};\n`;
          }

          // Add declarations before the main function
          preMain += varyingDeclarations;

          if (shaderType === 'vertex') {
            // In vertex shader, copy varying variables to output struct before return
            let copyStatements = '';
            for (const varyingVar of shader.hooks.varyingVariables) {
              const [varName] = varyingVar.split(':').map(s => s.trim());
              copyStatements += `  OUTPUT_VAR.${varName} = ${varName};\n`;
            }

            // Find the output variable name from the return statement and replace OUTPUT_VAR
            const returnMatch = postMain.match(/return\s+(\w+)\s*;/);
            if (returnMatch) {
              const outputVarName = returnMatch[1];
              copyStatements = copyStatements.replace(/OUTPUT_VAR/g, outputVarName);
              // Insert before the return statement
              postMain = postMain.replace(/(return\s+\w+\s*;)/g, `${copyStatements}  $1`);
            }
          } else if (shaderType === 'fragment') {
            // In fragment shader, initialize varying variables from input struct at start of main
            let initStatements = '';
            for (const varyingVar of shader.hooks.varyingVariables) {
              const [varName] = varyingVar.split(':').map(s => s.trim());
              initStatements += `  ${varName} = INPUT_VAR.${varName};\n`;
            }

            const mainStructParam = getMainStructParameter(main);
            if (mainStructParam) {
              const inputVarName = mainStructParam.inputName;
              initStatements = initStatements.replace(/INPUT_VAR/g, inputVarName);
              // Insert after the main function parameter but before any other code (anchored to start)
              postMain = initStatements + postMain;
            }
          }
        }

        // Handle instanceID varying for fragment access
        if (shader.hooks.instanceIDVarying) {
          const { name, declaration, source, interpolation } = shader.hooks.instanceIDVarying;
          const nextLocIndex = this._getNextAvailableLocation(preMain, shaderType);
          const interpAttr = interpolation ? ` @interpolate(${interpolation})` : '';
          const [varName, varType] = declaration.split(':').map(s => s.trim());
          const structMember = `@location(${nextLocIndex})${interpAttr} ${declaration},`;

          if (shaderType === 'vertex') {
            // Inject into VertexOutput struct
            preMain = preMain.replace(
              /struct\s+VertexOutput\s+\{([^}]*)\}/,
              (match, body) => `struct VertexOutput {${body}\n${structMember}}`
            );
            // Add private global
            preMain += `var<private> ${declaration};\n`;
            // Assign from built-in instanceID at start of main()
            postMain = `\n  ${varName} = ${source};\n` + postMain;
            // Copy to output struct before return
            const returnMatch = postMain.match(/return\s+(\w+)\s*;/);
            if (returnMatch) {
              const outputVarName = returnMatch[1];
              postMain = postMain.replace(
                /(return\s+\w+\s*;)/g,
                `${outputVarName}.${varName} = ${varName};\n  $1`
              );
            }
          } else if (shaderType === 'fragment') {
            // Inject into FragmentInput struct
            preMain = preMain.replace(
              /struct\s+FragmentInput\s+\{([^}]*)\}/,
              (match, body) => `struct FragmentInput {${body}\n${structMember}}`
            );
            // Add private global
            preMain += `var<private> ${declaration};\n`;
            // Initialize from input struct at start of main()
            const mainStructParam = getMainStructParameter(main);
            if (mainStructParam) {
              const inputVarName = mainStructParam.inputName;
              postMain = `\n  ${varName} = ${inputVarName}.${varName};\n` + postMain;
            }
          }
        }

        let hooks = '';
        let defines = '';
        if (shader.hooks.declarations) {
          hooks += shader.hooks.declarations + '\n';
        }
        if (shader.hooks[shaderType] && shader.hooks[shaderType].declarations) {
          hooks += shader.hooks[shaderType].declarations + '\n';
        }
        for (const hookDef in shader.hooks.helpers) {
          const [hookType, hookName] = hookDef.split(' ');
          const [_, params, body] = /^(\([^\)]*\))((?:.|\n)*)$/.exec(shader.hooks.helpers[hookDef]);
          if (hookType === 'void') {
            hooks += `fn ${hookName}${params}${body}\n`;
          } else {
            hooks += `fn ${hookName}${params} -> ${hookType}${body}\n`;
          }
        }
        for (const hookDef in shader.hooks[shaderType]) {
          if (hookDef === 'declarations') continue;
          const [hookType, hookName] = hookDef.split(' ');

          // Add a const so that if the shader wants to
          // optimize away the extra function calls in main, it can do so
          defines += `const AUGMENTED_HOOK_${hookName} = ${
          shader.hooks.modified[shaderType][hookDef] ? 'true' : 'false'
        };\n`;

          let [_, params, body] = /^(\([^\)]*\))((?:.|\n)*)$/.exec(shader.hooks[shaderType][hookDef]);

          params = appendHookParams(params, hookExtraParams);

          if (hookType === 'void') {
            hooks += `fn HOOK_${hookName}${params}${body}\n`;
          } else {
            hooks += `fn HOOK_${hookName}${params} -> ${hookType}${body}\n`;
          }
        }

        // Pass stage-specific builtins from main to each hook call.
        // Collect ALL HOOK_ calls (including nested ones) then insert
        // extra args from right to left so position shifts don't
        // invalidate earlier insertion points.
        if (hookExtraArgs.length > 0) {
          const addHookArgs = (src) => {
            const insertions = [];
            let searchIdx = 0;
            let m;
            while ((m = /HOOK_\w+\(/.exec(src.slice(searchIdx))) !== null) {
              const openParen = searchIdx + m.index + m[0].length - 1;
              let pos = openParen + 1;
              let nesting = 1;
              let hasParams = false;
              while (pos < src.length && nesting > 0) {
                if (src[pos] === '(') nesting++;
                else if (src[pos] === ')') {
                  nesting--;
                  if (nesting === 0) break;
                } else if (/\S/.test(src[pos])) {
                  hasParams = true;
                }
                pos++;
              }
              insertions.push({ pos, hasParams });
              searchIdx = openParen + 1;
            }

            insertions.sort((a, b) => b.pos - a.pos);

            let result = src;
            for (const { pos, hasParams } of insertions) {
              const insertion = (hasParams ? ', ' : '') + hookExtraArgs.join(', ');
              result = result.slice(0, pos) + insertion + result.slice(pos);
            }
            return result;
          };
          preMain = addHookArgs(preMain);
          postMain = addHookArgs(postMain);
        }

        return preMain + '\n' + defines + hooks + main + postMain;
      }

      _getNextAvailableLocation(shaderSource, shaderType) {
        // Parse existing struct to find the highest @location number
        let maxLocation = -1;
        const structName = shaderType === 'vertex' ? 'VertexOutput' : 'FragmentInput';

        // Find the struct definition
        const structMatch = shaderSource.match(new RegExp(`struct\\s+${structName}\\s*\\{([^}]*)\\}`, 's'));
        if (structMatch) {
          const structBody = structMatch[1];

          // Find all @location(N) declarations
          const locationMatches = structBody.matchAll(/@location\((\d+)\)/g);
          for (const match of locationMatches) {
            const locationNum = parseInt(match[1]);
            if (locationNum > maxLocation) {
              maxLocation = locationNum;
            }
          }
        }

        return maxLocation + 1;
      }

      getShaderHookTypes(shader, hookName) {
        // Create mapping from WGSL types to DataType entries
        const wgslToDataType = {
          'f32': DataType.float1,
          'vec2<f32>': DataType.float2,
          'vec3<f32>': DataType.float3,
          'vec4<f32>': DataType.float4,
          'vec2f': DataType.float2,
          'vec3f': DataType.float3,
          'vec4f': DataType.float4,
          'i32': DataType.int1,
          'vec2<i32>': DataType.int2,
          'vec3<i32>': DataType.int3,
          'vec4<i32>': DataType.int4,
          'bool': DataType.bool1,
          'vec2<bool>': DataType.bool2,
          'vec3<bool>': DataType.bool3,
          'vec4<bool>': DataType.bool4,
          'mat2x2<f32>': DataType.mat2,
          'mat3x3<f32>': DataType.mat3,
          'mat4x4<f32>': DataType.mat4,
          'texture_2d<f32>': DataType.sampler2D
        };

        let fullSrc = shader._vertSrc;
        let body = shader.hooks.vertex[hookName];
        if (!body) {
          body = shader.hooks.fragment[hookName];
          fullSrc = shader._fragSrc;
        }
        if (!body) {
          body = shader.hooks.compute[hookName];
          fullSrc = shader._computeSrc;
        }
        if (!body) {
          throw new Error(`Can't find hook ${hookName}!`);
        }
        const nameParts = hookName.split(/\s+/g);
        const functionName = nameParts.pop();
        const returnType = nameParts.pop();
        const returnQualifiers = [...nameParts];
        const parameterMatch = /\(([^\)]*)\)/.exec(body);
        if (!parameterMatch) {
          throw new Error(`Couldn't find function parameters in hook body:\n${body}`);
        }

        const structProperties = structName => {
          // WGSL struct parsing: struct StructName { field1: Type, field2: Type }
          const structDefMatch = new RegExp(`struct\\s+${structName}\\s*{([^}]*)}`).exec(fullSrc);
          if (!structDefMatch) return undefined;
          const properties = [];

          // Parse WGSL struct fields (e.g., "texCoord: vec2<f32>,")
          for (const fieldSrc of structDefMatch[1].split(',')) {
            const trimmed = fieldSrc.trim();
            if (!trimmed) continue;

            // Remove location decorations and parse field
            // Format: [@location(N)] fieldName: Type
            const fieldMatch = /(?:@location\([^)]*\)\s*)?(\w+)\s*:\s*([^,\s]+)/.exec(trimmed);
            if (!fieldMatch) continue;

            const name = fieldMatch[1];
            let typeName = fieldMatch[2];

            const dataType = wgslToDataType[typeName] || null;

            const typeProperties = structProperties(typeName);
            properties.push({
              name,
              type: {
                typeName: typeName, // Keep native WGSL type name
                qualifiers: [],
                properties: typeProperties,
                dataType: dataType
              }
            });
          }
          return properties;
        };

        const parameters = parameterMatch[1].split(',').map(paramString => {
          // WGSL function parameters: name: type or name: binding<type>
          const trimmed = paramString.trim();
          if (!trimmed) return null;

          const parts = trimmed.split(':').map(s => s.trim());
          if (parts.length !== 2) return null;

          const name = parts[0];
          let typeName = parts[1];

          // Handle texture bindings like "texture_2d<f32>" -> sampler2D DataType
          if (typeName.includes('texture_2d')) {
            typeName = 'texture_2d<f32>';
          }

          const dataType = wgslToDataType[typeName] || null;

          const properties = structProperties(typeName);
          return {
            name,
            type: {
              typeName: typeName, // Keep native WGSL type name
              qualifiers: [],
              properties,
              dataType: dataType
            }
          };
        }).filter(Boolean);

        // Convert WGSL return type to DataType
        const returnDataType = wgslToDataType[returnType] || null;

        return {
          name: functionName,
          returnType: {
            typeName: returnType, // Keep native WGSL type name
            qualifiers: returnQualifiers,
            properties: structProperties(returnType),
            dataType: returnDataType
          },
          parameters
        };
      }

      //////////////////////////////////////////////
      // Buffer management for pixel reading
      //////////////////////////////////////////////

      _ensurePixelReadBuffer(requiredSize) {
        // Create or resize staging buffer if needed
        if (!this.pixelReadBuffer || this.pixelReadBufferSize < requiredSize) {
          // Clean up old buffer
          if (this.pixelReadBuffer) {
            this.flushDraw();
            this.pixelReadBuffer.destroy();
          }

          // Create new buffer with padding to avoid frequent recreations
          // Scale by 2 to ensure integer size and reasonable headroom
          const bufferSize = Math.max(requiredSize, this.pixelReadBufferSize * 2);
          this.pixelReadBuffer = this.device.createBuffer({
            size: bufferSize,
            usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ,
          });
          this.pixelReadBufferSize = bufferSize;
        }
        return this.pixelReadBuffer;
      }

      _alignBytesPerRow(bytesPerRow) {
        // WebGPU requires bytesPerRow to be a multiple of 256 bytes for texture-to-buffer copies
        return Math.ceil(bytesPerRow / 256) * 256;
      }

      //////////////////////////////////////////////
      // Framebuffer methods
      //////////////////////////////////////////////

      defaultFramebufferAlpha() {
        return true
      }

      defaultFramebufferAntialias() {
        return this._pInst._webgpuAttributes?.antialias !== false;
      }

      supportsFramebufferAntialias() {
        return true;
      }

      createFramebufferResources(framebuffer) {
      }

      validateFramebufferFormats(framebuffer) {
        if (![
          UNSIGNED_BYTE,
          FLOAT,
          HALF_FLOAT
        ].includes(framebuffer.format)) {
          console.warn(
            'Unknown Framebuffer format. ' +
              'Please use UNSIGNED_BYTE, FLOAT, or HALF_FLOAT. ' +
              'Defaulting to UNSIGNED_BYTE.'
          );
          framebuffer.format = UNSIGNED_BYTE;
        }

        if (framebuffer.useDepth && ![
          UNSIGNED_INT,
          FLOAT
        ].includes(framebuffer.depthFormat)) {
          console.warn(
            'Unknown Framebuffer depth format. ' +
              'Please use UNSIGNED_INT or FLOAT. Defaulting to FLOAT.'
          );
          framebuffer.depthFormat = FLOAT;
        }
      }

      recreateFramebufferTextures(framebuffer) {
        this.flushDraw();
        // Clean up existing textures
        if (framebuffer.colorTexture && framebuffer.colorTexture.destroy) {
          const tex = framebuffer.colorTexture;
          this._postSubmitCallbacks.push(() => tex.destroy());
          framebuffer.colorTextureView = null;
        }
        if (framebuffer.aaColorTexture && framebuffer.aaColorTexture.destroy) {
          const tex = framebuffer.aaColorTexture;
          this._postSubmitCallbacks.push(() => tex.destroy());
          framebuffer.aaColorTextureView = null;
        }
        if (framebuffer.depthTexture && framebuffer.depthTexture.destroy) {
          const tex = framebuffer.depthTexture;
          this._postSubmitCallbacks.push(() => tex.destroy());
          framebuffer.depthTextureView = null;
        }
        if (framebuffer.aaDepthTexture && framebuffer.aaDepthTexture.destroy) {
          const tex = framebuffer.aaDepthTexture;
          this._postSubmitCallbacks.push(() => tex.destroy());
          framebuffer.aaDepthTextureView = null;
        }

        const baseDescriptor = {
          size: {
            width: framebuffer.width * framebuffer.density,
            height: framebuffer.height * framebuffer.density,
            depthOrArrayLayers: 1,
          },
          format: this._getWebGPUColorFormat(framebuffer),
        };

        // Create non-multisampled texture for texture binding (always needed)
        const colorTextureDescriptor = {
          ...baseDescriptor,
          usage: GPUTextureUsage.RENDER_ATTACHMENT |
                 GPUTextureUsage.TEXTURE_BINDING |
                 GPUTextureUsage.COPY_SRC |
                 (framebuffer._useCanvasFormat ? GPUTextureUsage.COPY_DST : 0),
          sampleCount: 1,
        };
        framebuffer.colorTexture = this.device.createTexture(colorTextureDescriptor);
        framebuffer.colorTextureView = framebuffer.colorTexture.createView();

        // Create multisampled texture for rendering if antialiasing is enabled
        if (framebuffer.antialias) {
          const aaColorTextureDescriptor = {
            ...baseDescriptor,
            usage: GPUTextureUsage.RENDER_ATTACHMENT,
            sampleCount: this._getValidSampleCount(framebuffer.antialiasSamples),
          };
          framebuffer.aaColorTexture = this.device.createTexture(aaColorTextureDescriptor);
          framebuffer.aaColorTextureView = framebuffer.aaColorTexture.createView();
        }

        if (framebuffer.useDepth) {
          const depthBaseDescriptor = {
            size: {
              width: framebuffer.width * framebuffer.density,
              height: framebuffer.height * framebuffer.density,
              depthOrArrayLayers: 1,
            },
            format: this._getWebGPUDepthFormat(framebuffer),
          };

          // Create non-multisampled depth texture for texture binding (always needed)
          const depthTextureDescriptor = {
            ...depthBaseDescriptor,
            usage: GPUTextureUsage.RENDER_ATTACHMENT |
                   GPUTextureUsage.TEXTURE_BINDING |
                   (framebuffer._useCanvasFormat ? GPUTextureUsage.COPY_DST : 0),
            sampleCount: 1,
          };
          framebuffer.depthTexture = this.device.createTexture(depthTextureDescriptor);
          framebuffer.depthTextureView = framebuffer.depthTexture.createView();

          // Create multisampled depth texture for rendering if antialiasing is enabled
          if (framebuffer.antialias) {
            const aaDepthTextureDescriptor = {
              ...depthBaseDescriptor,
              usage: GPUTextureUsage.RENDER_ATTACHMENT,
              sampleCount: this._getValidSampleCount(framebuffer.antialiasSamples),
            };
            framebuffer.aaDepthTexture = this.device.createTexture(aaDepthTextureDescriptor);
            framebuffer.aaDepthTextureView = framebuffer.aaDepthTexture.createView();
          }
        }

        // Clear the framebuffer textures after creation
        this._clearFramebufferTextures(framebuffer);
      }

      _clearFramebufferTextures(framebuffer) {
        this._finishActiveRenderPass();
        const commandEncoder = this.device.createCommandEncoder();

        // Clear the color texture (and multisampled texture if it exists)
        const colorAttachment = {
          view: framebuffer.aaColorTexture
            ? framebuffer.aaColorTextureView
            : framebuffer.colorTextureView,
          loadOp: "clear",
          storeOp: "store",
          clearValue: { r: 0, g: 0, b: 0, a: 0 },
          resolveTarget: framebuffer.aaColorTexture
            ? framebuffer.colorTextureView
            : undefined,
        };

        // Clear the depth texture if it exists
        const depthTexture = framebuffer.aaDepthTexture || framebuffer.depthTexture;
        const depthStencilAttachment = depthTexture ? {
          view: framebuffer.aaDepthTexture
            ? framebuffer.aaDepthTextureView
            : framebuffer.depthTextureView,
          depthLoadOp: "clear",
          depthStoreOp: "store",
          depthClearValue: 1.0,
          stencilLoadOp: "clear",
          stencilStoreOp: "store",
          depthReadOnly: false,
          stencilReadOnly: false,
        } : undefined;

        const renderPassDescriptor = {
          colorAttachments: [colorAttachment],
          depthStencilAttachment: depthStencilAttachment,
        };

        const passEncoder = commandEncoder.beginRenderPass(renderPassDescriptor);
        passEncoder.end();

        this._pendingCommandEncoders.push(commandEncoder.finish());
        this._hasPendingDraws = true;
      }

      _getFramebufferColorTextureView(framebuffer) {
        if (framebuffer.colorTexture) {
          return framebuffer.colorTextureView;
        }
        return null;
      }

      createFramebufferTextureHandle(framebufferTexture) {
        const src = framebufferTexture;
        let renderer = this;
        return {
          get view() {
            return renderer._getFramebufferColorTextureView(src.framebuffer);
          },
          get gpuTexture() {
            return src.framebuffer.colorTexture;
          }
        };
      }

      // Maps a plain JS value to the WGSL type string that represents it in a struct.
      _jsValueToWgslType(value) {
        if (typeof value === 'number') return 'f32';
        // Duck typing instead of instanceof to avoid importing a separate
        // copy of the Color/Vector classes
        if (value?.isVector) {
          if (value.dimensions === 2) return 'vec2f';
          if (value.dimensions === 3) return 'vec3f';
          if (value.dimensions === 4) return 'vec4f';
          throw new Error(`Unsupported vector dimension ${value.dimensions} for struct storage field`);
        }
        if (value?.isColor) {
          return 'vec4f';
        }
        if (Array.isArray(value)) {
          if (value.length === 2) return 'vec2f';
          if (value.length === 3) return 'vec3f';
          if (value.length === 4) return 'vec4f';
          throw new Error(`Unsupported array length ${value.length} for struct storage field`);
        }
        throw new Error(`Unsupported value type ${typeof value} for struct storage field`);
      }

      // Infers a struct schema from the first element of a struct array.
      //
      // Returns { fields, stride, structBody } where:
      //   fields: field has the _packField interface (baseType, size, offset, packInPlace) plus:
      //     name: string - JS property name
      //     dim:  number - float component count, used when creating StrandsNodes
      //   structBody: everything inside the  { ... } of a WGSL struct definition
      //   stride: how many bytes are reserved for this struct in the buffer
      _inferStructSchema(firstElement) {
        const entries = Object.entries(firstElement);

        if (!p5.disableFriendlyErrors) {
          for (const [name, value] of entries) {
            if (
              value !== null &&
              typeof value === 'object' &&
              !Array.isArray(value) &&
              // Duck typing instead of instanceof to avoid importing a separate
              // copy of the Color/Vector classes
              !value?.isVector &&
              !value?.isColor
            ) {
              p5._friendlyError(
                `The "${name}" property in your storage data contains a nested object. ` +
                `Make sure you only use properties with numbers, arrays of numbers, or p5.Vector.`,
                'createStorage'
              );
            }
          }
        }

        const fieldLines = entries.map(([name, value]) =>
          `  ${name}: ${this._jsValueToWgslType(value)},`
        ).join('\n');
        const structBody = `{\n${fieldLines}\n}`;
        const elements = this._parseStruct(`struct _Tmp ${structBody}`, '_Tmp');

        let maxEnd = 0;
        let maxAlign = 1;
        const fields = entries.map(([name, value]) => {
          const el = elements[name];
          maxEnd = Math.max(maxEnd, el.offsetEnd);
          // Alignment for scalars/vectors: <=4 -> 4, <=8 -> 8, else 16
          const align = el.size <= 4 ? 4 : el.size <= 8 ? 8 : 16;
          maxAlign = Math.max(maxAlign, align);
          // Track original JS type for reconstruction during readback
          const kind = value?.isVector ? 'vector'
            : value?.isColor ? 'color'
            : undefined;
          return {
            name,
            baseType: el.baseType,
            size: el.size,
            offset: el.offset,
            packInPlace: el.packInPlace ?? false,
            dim: el.size / 4,
            kind,
          };
        });

        const stride = Math.ceil(maxEnd / maxAlign) * maxAlign;
        return { fields, stride, structBody };
      }

      // Packs an array of plain objects into a Float32Array using the given struct schema.
      // Reuses _packField so layout rules match uniform packing exactly.
      _packStructArray(data, schema) {
        const { fields, stride } = schema;
        const totalBytes = Math.max(data.length * stride, 16);
        const alignedBytes = Math.ceil(totalBytes / 16) * 16;
        const buffer = new ArrayBuffer(alignedBytes);
        const floatView = new Float32Array(buffer);
        const dataView = new DataView(buffer);
        for (let i = 0; i < data.length; i++) {
          const item = data[i];
          const baseOffset = i * stride;
          for (const field of fields) {
            this._packField(field, item[field.name], floatView, dataView, baseOffset);
          }
        }
        return floatView;
      }

      // Inverse of _packStructArray reads packed buffer back into plain JS objects
      // using the same schema layout - fields, stride and offsets
      _unpackStructArray(floatView, schema) {
        const { fields, stride } = schema;
        const dataView = new DataView(floatView.buffer);
        const count = Math.floor(floatView.byteLength / stride);
        const result = [];

        for (let i = 0; i < count; i++) {
          const item = {};
          const baseOffset = i * stride;
          for (const field of fields) {
            const byteOffset = baseOffset + field.offset;
            const n = field.size / 4;

            if (field.baseType === 'u32') {
              if (n === 1) {
                item[field.name] = dataView.getUint32(byteOffset, true);
              } else {
                item[field.name] = Array.from({ length: n }, (_, j) =>
                  dataView.getUint32(byteOffset + j * 4, true)
                );
              }
            } else if (field.baseType === 'i32') {
              if (n === 1) {
                item[field.name] = dataView.getInt32(byteOffset, true);
              } else {
                item[field.name] = Array.from({ length: n }, (_, j) =>
                  dataView.getInt32(byteOffset + j * 4, true)
                );
              }
            } else {
              const idx = byteOffset / 4;
              if (n === 1) {
                item[field.name] = floatView[idx];
              } else {
                const values = Array.from(floatView.slice(idx, idx + n));
                if (field.kind === 'vector') {
                  item[field.name] = this._pInst.createVector(...values);
                } else if (field.kind === 'color') {
                  // Color was packed as normalized RGBA [0-1] via _getRGBA([1,1,1,1])
                  // Scale back to the current colorMode range
                  const maxes = this.states.colorMaxes[this.states.colorMode];
                  item[field.name] = this._pInst.color(
                    values[0] * maxes[0], values[1] * maxes[1],
                    values[2] * maxes[2], values[3] * maxes[3]
                  );
                } else {
                  item[field.name] = values;
                }
              }
            }
          }
          result.push(item);
        }

        return result;
      }

      createStorage(dataOrCount) {
        const device = this.device;

        // Struct array: an array of plain objects
        if (Array.isArray(dataOrCount) && dataOrCount.length > 0 &&
            typeof dataOrCount[0] === 'object' && !Array.isArray(dataOrCount[0])) {
          if (!p5.disableFriendlyErrors && dataOrCount.length > 1) {
            const firstKeys = Object.keys(dataOrCount[0]);
            let warned = false;
            for (let i = 1; i < dataOrCount.length; i++) {
              const el = dataOrCount[i];
              const elKeys = Object.keys(el);
              const sameKeys = firstKeys.length === elKeys.length &&
                firstKeys.every((k, j) => k === elKeys[j]);
              if (!sameKeys) {
                p5._friendlyError(
                  `Element ${i} has different fields than element 0. ` +
                  `All elements should have the same properties.`,
                  'createStorage'
                );
                break;
              }
              for (const key of firstKeys) {
                const firstType = this._jsValueToWgslType(dataOrCount[0][key]);
                const elType = this._jsValueToWgslType(el[key]);
                if (firstType !== elType) {
                  p5._friendlyError(
                    `The "${key}" property of element ${i} has type ${elType} ` +
                    `but element 0 has type ${firstType}. Proporties should have the same type across all elements.`,
                    'createStorage'
                  );
                  warned = true;
                  break;
                }
              }
              if (warned) break;
            }
          }
          const schema = this._inferStructSchema(dataOrCount[0]);
          const packed = this._packStructArray(dataOrCount, schema);
          const size = packed.byteLength;
          const buffer = device.createBuffer({
            size,
            usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST | GPUBufferUsage.COPY_SRC,
            mappedAtCreation: true,
          });
          new Float32Array(buffer.getMappedRange()).set(packed);
          buffer.unmap();
          const storageBuffer = new StorageBuffer(buffer, size, this, schema);
          this._storageBuffers.add(storageBuffer);
          return storageBuffer;
        }

        // Determine buffer size and initial data
        let size, initialData;
        if (typeof dataOrCount === 'number') {
          // createStorage(count) - zero-initialized
          size = dataOrCount * 4; // floats are 4 bytes
          initialData = new Float32Array(dataOrCount);
        } else {
          // createStorage(array) - from data
          if (dataOrCount instanceof Float32Array) {
            initialData = dataOrCount;
          } else if (Array.isArray(dataOrCount)) {
            initialData = new Float32Array(dataOrCount);
          } else {
            throw new Error('createStorage expects a number or array/Float32Array');
          }
          size = initialData.byteLength;
        }

        // Align to 16 bytes (WGSL storage buffer alignment requirement)
        size = Math.ceil(size / 16) * 16;

        // Create storage buffer with STORAGE | COPY_DST | COPY_SRC usage
        const buffer = device.createBuffer({
          size,
          usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST | GPUBufferUsage.COPY_SRC,
          mappedAtCreation: initialData.length > 0
        });

        // Write initial data if provided
        if (initialData.length > 0) {
          const mapping = new Float32Array(buffer.getMappedRange());
          mapping.set(initialData);
          buffer.unmap();
        }

        const storageBuffer = new StorageBuffer(buffer, size, this);

        // Track for cleanup
        this._storageBuffers.add(storageBuffer);

        return storageBuffer;
      }

      _getWebGPUColorFormat(framebuffer) {
        if (framebuffer.format === FLOAT) {
          return framebuffer.channels === RGBA ? 'rgba32float' : 'rgba32float';
        } else if (framebuffer.format === HALF_FLOAT) {
          return framebuffer.channels === RGBA ? 'rgba16float' : 'rgba16float';
        } else {
          // Framebuffer with _useCanvasFormat should match canvas presentation format
          if (framebuffer._useCanvasFormat) {
            return this.presentationFormat;
          }
          // Other framebuffers use standard RGBA format
          return framebuffer.channels === RGBA ? 'rgba8unorm' : 'rgba8unorm';
        }
      }

      _getWebGPUDepthFormat(framebuffer) {
        if (framebuffer._useCanvasFormat) {
          return this.depthFormat;
        }
        if (framebuffer.useStencil) {
          return framebuffer.depthFormat === FLOAT ? 'depth32float-stencil8' : 'depth24plus-stencil8';
        } else {
          return framebuffer.depthFormat === FLOAT ? 'depth32float' : 'depth24plus';
        }
      }

      _deleteFramebufferTexture(texture) {
        this.flushDraw();
        const handle = texture.rawTexture();
        if (handle.texture && handle.texture.destroy) {
          const tex = handle.texture;
          this._postSubmitCallbacks.push(() => tex.destroy());
        }
        this.textures.delete(texture);
      }

      deleteFramebufferTextures(framebuffer) {
        this._deleteFramebufferTexture(framebuffer.color);
        if (framebuffer.depth) this._deleteFramebufferTexture(framebuffer.depth);
      }

      deleteFramebufferResources(framebuffer) {
        this.flushDraw();
        if (framebuffer.colorTexture && framebuffer.colorTexture.destroy) {
          const tex = framebuffer.colorTexture;
          this._postSubmitCallbacks.push(() => tex.destroy());
        }
        if (framebuffer.depthTexture && framebuffer.depthTexture.destroy) {
          const tex = framebuffer.depthTexture;
          this._postSubmitCallbacks.push(() => tex.destroy());
        }
        if (framebuffer.aaDepthTexture && framebuffer.aaDepthTexture.destroy) {
          const tex = framebuffer.aaDepthTexture;
          this._postSubmitCallbacks.push(() => tex.destroy());
        }
      }

      getFramebufferToBind(framebuffer) {
      }

      updateFramebufferTexture(framebuffer, property) {
        // No-op for WebGPU since antialiasing is handled at pipeline level
      }

      bindFramebuffer(framebuffer) {}

      framebufferYScale() {
        return 1;
      }

      async readFramebufferPixels(framebuffer) {
        this.flushDraw();
        // await this.finishDraw();
        // Ensure all pending GPU work is complete before reading pixels
        // await this.queue.onSubmittedWorkDone();

        const width = framebuffer.width * framebuffer.density;
        const height = framebuffer.height * framebuffer.density;
        const bytesPerPixel = 4;
        const unalignedBytesPerRow = width * bytesPerPixel;
        const alignedBytesPerRow = this._alignBytesPerRow(unalignedBytesPerRow);
        const bufferSize = alignedBytesPerRow * height;

        // const stagingBuffer = this._ensurePixelReadBuffer(bufferSize);
        const stagingBuffer = this.device.createBuffer({
          size: bufferSize,
          usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ,
        });

        const commandEncoder = this.device.createCommandEncoder();
        commandEncoder.copyTextureToBuffer(
          {
            texture: framebuffer.colorTexture,
            origin: { x: 0, y: 0, z: 0 },
            mipLevel: 0,
            aspect: 'all'
          },
          { buffer: stagingBuffer, bytesPerRow: alignedBytesPerRow, rowsPerImage: height },
          { width, height, depthOrArrayLayers: 1 }
        );

        this.device.queue.submit([commandEncoder.finish()]);

        // Wait for the copy operation to complete
        // await this.queue.onSubmittedWorkDone();

        await stagingBuffer.mapAsync(GPUMapMode.READ, 0, bufferSize);
        const mappedRange = stagingBuffer.getMappedRange(0, bufferSize);

        // If alignment was needed, extract the actual pixel data
        let result;
        if (alignedBytesPerRow === unalignedBytesPerRow) {
          result = new Uint8Array(mappedRange.slice(0, width * height * bytesPerPixel));
          stagingBuffer.unmap();
        } else {
          // Need to extract pixel data from aligned buffer
          result = new Uint8Array(width * height * bytesPerPixel);
          const mappedData = new Uint8Array(mappedRange);
          for (let y = 0; y < height; y++) {
            const srcOffset = y * alignedBytesPerRow;
            const dstOffset = y * unalignedBytesPerRow;
            result.set(mappedData.subarray(srcOffset, srcOffset + unalignedBytesPerRow), dstOffset);
          }
          stagingBuffer.unmap();
        }

        this._ensurePixelsAreRGBA(framebuffer, result);

        return result;
      }

      async readFramebufferPixel(framebuffer, x, y) {
        this.flushDraw();
        // await this.finishDraw();
        // Ensure all pending GPU work is complete before reading pixels
        // await this.queue.onSubmittedWorkDone();

        const bytesPerPixel = 4;
        const alignedBytesPerRow = this._alignBytesPerRow(bytesPerPixel);
        const bufferSize = alignedBytesPerRow;

        const stagingBuffer = this._ensurePixelReadBuffer(bufferSize);

        const commandEncoder = this.device.createCommandEncoder();
        commandEncoder.copyTextureToBuffer(
          {
            texture: framebuffer.colorTexture,
            origin: { x, y, z: 0 }
          },
          { buffer: stagingBuffer, bytesPerRow: alignedBytesPerRow },
          { width: 1, height: 1, depthOrArrayLayers: 1 }
        );

        this.device.queue.submit([commandEncoder.finish()]);

        await stagingBuffer.mapAsync(GPUMapMode.READ, 0, bufferSize);
        const mappedRange = stagingBuffer.getMappedRange(0, bufferSize);
        const pixelData = new Uint8Array(mappedRange);
        const result = [pixelData[0], pixelData[1], pixelData[2], pixelData[3]];


        this._ensurePixelsAreRGBA(framebuffer, result);

        stagingBuffer.unmap();
        return result;
      }

      async readFramebufferRegion(framebuffer, x, y, w, h) {
        this.flushDraw();
        // await this.finishDraw();
        // const wasActive = this.activeFramebuffer() === framebuffer;
        // if (wasActive) {
          // framebuffer.end();
          // this.flushDraw()
        // }
        // Ensure all pending GPU work is complete before reading pixels
        // await this.queue.onSubmittedWorkDone();

        const width = w * framebuffer.density;
        const height = h * framebuffer.density;
        const bytesPerPixel = 4;
        const unalignedBytesPerRow = width * bytesPerPixel;
        const alignedBytesPerRow = this._alignBytesPerRow(unalignedBytesPerRow);
        const bufferSize = alignedBytesPerRow * height;

        const stagingBuffer = this._ensurePixelReadBuffer(bufferSize);

        const commandEncoder = this.device.createCommandEncoder();
        commandEncoder.copyTextureToBuffer(
          {
            texture: framebuffer.colorTexture,
            mipLevel: 0,
            origin: { x: x * framebuffer.density, y: y * framebuffer.density, z: 0 }
          },
          { buffer: stagingBuffer, bytesPerRow: alignedBytesPerRow },
          { width, height, depthOrArrayLayers: 1 }
        );

        this.device.queue.submit([commandEncoder.finish()]);

        await stagingBuffer.mapAsync(GPUMapMode.READ, 0, bufferSize);
        const mappedRange = stagingBuffer.getMappedRange(0, bufferSize);

        let pixelData;
        if (alignedBytesPerRow === unalignedBytesPerRow) {
          pixelData = new Uint8Array(mappedRange.slice(0, width * height * bytesPerPixel));
        } else {
          // Need to extract pixel data from aligned buffer
          pixelData = new Uint8Array(width * height * bytesPerPixel);
          const mappedData = new Uint8Array(mappedRange);
          for (let y = 0; y < height; y++) {
            const srcOffset = y * alignedBytesPerRow;
            const dstOffset = y * unalignedBytesPerRow;
            pixelData.set(mappedData.subarray(srcOffset, srcOffset + unalignedBytesPerRow), dstOffset);
          }
        }
        this._ensurePixelsAreRGBA(framebuffer, pixelData);

        // WebGPU doesn't need vertical flipping unlike WebGL
        const region = new Image(width, height);
        region.imageData = region.canvas.getContext('2d').createImageData(width, height);
        region.imageData.data.set(pixelData);
        region.pixels = region.imageData.data;
        region.updatePixels();

        if (framebuffer.density !== 1) {
          region.pixelDensity(framebuffer.density);
        }

        stagingBuffer.unmap();
        // if (wasActive) framebuffer.begin();
        return region;
      }

      updateFramebufferPixels(framebuffer) {
        const width = framebuffer.width * framebuffer.density;
        const height = framebuffer.height * framebuffer.density;
        const bytesPerPixel = 4;

        const expectedLength = width * height * bytesPerPixel;
        if (!framebuffer.pixels || framebuffer.pixels.length !== expectedLength) {
          throw new Error(
            'The pixels array has not been set correctly. Please call loadPixels() before updatePixels().'
          );
        }

        this.device.queue.writeTexture(
          { texture: framebuffer.colorTexture },
          framebuffer.pixels,
          {
            bytesPerRow: width * bytesPerPixel,
            rowsPerImage: height
          },
          { width, height, depthOrArrayLayers: 1 }
        );
      }

      //////////////////////////////////////////////
      // Main canvas pixel methods
      //////////////////////////////////////////////

      _ensurePixelsAreRGBA(framebuffer, result) {
        // Convert BGRA to RGBA if reading from canvas-format framebuffer on BGRA systems
        if (framebuffer._useCanvasFormat && this.presentationFormat === 'bgra8unorm') {
          this._convertBGRtoRGB(result);
        }
      }

      _convertBGRtoRGB(pixelData) {
        for (let i = 0; i < pixelData.length; i += 4) {
          const temp = pixelData[i];
          pixelData[i] = pixelData[i + 2];
          pixelData[i + 2] = temp;
        }
      }

      async loadPixels() {
        this._promoteToFramebuffer();
        await this.mainFramebuffer.loadPixels();
        this.pixels = this.mainFramebuffer.pixels.slice();
      }

      async get(x, y, w, h) {
        this._promoteToFramebuffer();
        return this.mainFramebuffer.get(x, y, w, h);
      }

      filter(...args) {
        // If no custom framebuffer is active, promote to mainFramebuffer
        if (!this.activeFramebuffer()) {
          this._promoteToFramebuffer();
        }

        return super.filter(...args);
      }


      baseFilterShader() {
        if (!this._baseFilterShader) {
          this._baseFilterShader = new Shader(
            this,
            baseFilterVertexShader,
            baseFilterFragmentShader,
            {
              vertex: {},
              fragment: {
                "vec4<f32> getColor": `(inputs: FilterInputs, canvasContent: texture_2d<f32>, canvasContent_sampler: sampler) -> vec4<f32> {
                return textureSample(tex, tex_sampler, inputs.texCoord);
              }`,
              },
              hookAliases: {
                'getColor': ['filterColor'],
              },
            }
          );
        }
        return this._baseFilterShader;
      }

      baseComputeShader() {
        if (!this._baseComputeShader) {
          this._baseComputeShader = new Shader(
            this,
            baseComputeShader,
            {
              compute: {
                'void iteration': '(index: vec3<i32>) {}',
              },
            }
          );
        }
        return this._baseComputeShader;
      }

      /*
       * WebGPU-specific implementation of imageLight shader creation
       */
      _createImageLightShader(type) {
        if (type === 'diffused') {
          return this._pInst.createShader(
            imageLightVertexShader,
            imageLightDiffusedFragmentShader
          );
        } else if (type === 'specular') {
          return this._pInst.createShader(
            imageLightVertexShader,
            imageLightSpecularFragmentShader
          );
        }
        throw new Error(`Unknown imageLight shader type: ${type}`);
      }

      /*
       * WebGPU-specific implementation of mipmap texture creation
       */
      _createMipmapTexture(levels) {
        return new MipmapTexture(this, levels, {});
      }

      /*
       * Prepare WebGPU texture to accumulate mip levels directly
       */
      _prepareMipmapData(size, mipLevels) {
        // Create WebGPU texture with mipmaps upfront
        const textureDescriptor = {
          size: {
            width: size,
            height: size,
            depthOrArrayLayers: 1,
          },
          mipLevelCount: mipLevels,
          format: 'rgba8unorm',
          usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST | GPUTextureUsage.RENDER_ATTACHMENT,
        };

        const gpuTexture = this.device.createTexture(textureDescriptor);

        return {
          gpuTexture,
          size,
          mipLevels,
          format: 'rgba8unorm'
        };
      }

      /*
       * Copy framebuffer content directly to WebGPU texture mip level
       */
      _accumulateMipLevel(framebuffer, mipmapData, mipLevel, width, height) {
        this.flushDraw();
        // Copy from framebuffer texture to the mip level
        const commandEncoder = this.device.createCommandEncoder();

        // Get the underlying WebGPU texture from the framebuffer
        const sourceTexture = framebuffer.color.rawTexture().texture;

        commandEncoder.copyTextureToTexture(
          {
            texture: sourceTexture,
            origin: { x: 0, y: 0, z: 0 },
          },
          {
            texture: mipmapData.gpuTexture,
            mipLevel: mipLevel,
            origin: { x: 0, y: 0, z: 0 },
          },
          {
            width: width,
            height: height,
            depthOrArrayLayers: 1,
          }
        );

        this.device.queue.submit([commandEncoder.finish()]);
      }

      /*
       * Create final MipmapTexture from WebGPU texture
       */
      _finalizeMipmapTexture(mipmapData) {
        // Create a MipmapTexture that wraps the pre-built WebGPU texture
        return new MipmapTexture(this, mipmapData, {});
      }

      createMipmapTextureHandle({ gpuTexture, format, dataType, width, height }) {
        // WebGPU always uses pre-built GPU textures for mipmaps
        return {
          texture: gpuTexture,
          view: gpuTexture.createView(),
          glFormat: format || 'rgba8unorm',
          glDataType: dataType || 'uint8'
        };
      }

      compute(shader, x, y = 1, z = 1) {
        if (shader.shaderType !== 'compute') {
          throw new Error('compute() can only be called with a compute shader');
        }

        this._finishActiveRenderPass();

        // Ensure shader is initialized and finalized
        if (!shader._compiled) {
          shader.init();
        }

        // Set default uniforms
        shader.setDefaultUniforms();
        shader.setUniform('uTotalCount', [x, y, z]);

        // Calculate optimal workgroup size (8x8x1 = 64 threads per workgroup)
        const WORKGROUP_SIZE_X = 8;
        const WORKGROUP_SIZE_Y = 8;
        const WORKGROUP_SIZE_Z = 1;

        // auto spreading: if any dimension is too large or for performance optimization,
        // spread total iteration count across dimensions
        const totalIterations = x * y * z;
        const MAX_THREADS_PER_DIM = 65535 * 8;

        let px = x;
        let py = y;
        let pz = z;

        // we spread if we exceed GPU limits OR if it involves a large 1D dispatch
        const exceedsLimits = x > MAX_THREADS_PER_DIM || y > MAX_THREADS_PER_DIM || z > MAX_THREADS_PER_DIM;
        const isLarge1D = totalIterations > 1024 && y === 1 && z === 1;

        if (exceedsLimits || isLarge1D) {
          // Always use 2D square spreading (√N × √N).
          // Benchmarks showed 2D square equals or outperforms 3D cube at every
          // scale tested, with simpler index reconstruction in the shader.
          px = Math.ceil(Math.sqrt(totalIterations));
          py = Math.ceil(totalIterations / px);
          pz = 1;
        }

        shader.setUniform('uPhysicalCount', [px, py, pz]);

        const workgroupCountX = Math.ceil(px / WORKGROUP_SIZE_X);
        const workgroupCountY = Math.ceil(py / WORKGROUP_SIZE_Y);
        const workgroupCountZ = Math.ceil(pz / WORKGROUP_SIZE_Z);

        const commandEncoder = this.device.createCommandEncoder();
        const passEncoder = commandEncoder.beginComputePass();
        this.setupShaderBindGroups(shader, passEncoder, {
          compute: true,
          workgroupSize: [WORKGROUP_SIZE_X, WORKGROUP_SIZE_Y, WORKGROUP_SIZE_Z],
        });

        // Dispatch compute workgroups
        passEncoder.dispatchWorkgroups(workgroupCountX, workgroupCountY, workgroupCountZ);

        passEncoder.end();
        this.device.queue.submit([commandEncoder.finish()]);
      }
    }

    p5.RendererWebGPU = RendererWebGPU;

    p5.renderers[WEBGPU] = p5.RendererWebGPU;

    // TODO: move this and the duplicate in the WebGL renderer to another file
    fn.setAttributes = async function (key, value) {
      return this._renderer._setAttributes(key, value);
    };

  }

  if (typeof p5 !== "undefined") {
    rendererWebGPU(p5, p5.prototype);
  }

  function index(p5){
    p5.registerAddon(rendererWebGPU);
  }

  return index;

})();
