@driftengine/ui2d 3.61.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +9 -0
  3. package/README.md +228 -0
  4. package/dist/camera2d.d.ts +47 -0
  5. package/dist/camera2d.js +46 -0
  6. package/dist/index.d.ts +30 -0
  7. package/dist/index.js +21 -0
  8. package/dist/shaders/generated/sprite.wgsl.d.ts +54 -0
  9. package/dist/shaders/generated/sprite.wgsl.js +60 -0
  10. package/dist/shaders/sprite.d.ts +3 -0
  11. package/dist/shaders/sprite.js +98 -0
  12. package/dist/spriteBatch.d.ts +78 -0
  13. package/dist/spriteBatch.js +93 -0
  14. package/dist/spriteGl.d.ts +26 -0
  15. package/dist/spriteGl.js +207 -0
  16. package/dist/spriteGpu.d.ts +31 -0
  17. package/dist/spriteGpu.js +201 -0
  18. package/dist/spritePass.d.ts +54 -0
  19. package/dist/spritePass.js +142 -0
  20. package/dist/spriteSheet.d.ts +64 -0
  21. package/dist/spriteSheet.js +84 -0
  22. package/dist/spriteTexture.d.ts +36 -0
  23. package/dist/spriteTexture.js +5 -0
  24. package/dist/tilemap.d.ts +47 -0
  25. package/dist/tilemap.js +70 -0
  26. package/dist/uiDraw.d.ts +28 -0
  27. package/dist/uiDraw.js +41 -0
  28. package/dist/uiFocus.d.ts +36 -0
  29. package/dist/uiFocus.js +78 -0
  30. package/dist/uiInput.d.ts +51 -0
  31. package/dist/uiInput.js +85 -0
  32. package/dist/uiLayout.d.ts +23 -0
  33. package/dist/uiLayout.js +144 -0
  34. package/dist/uiNode.d.ts +105 -0
  35. package/dist/uiNode.js +79 -0
  36. package/package.json +57 -0
  37. package/src/camera2d.ts +88 -0
  38. package/src/index.ts +55 -0
  39. package/src/shaders/generated/sprite.wgsl.ts +63 -0
  40. package/src/shaders/sprite.ts +102 -0
  41. package/src/spriteBatch.ts +169 -0
  42. package/src/spriteGl.ts +270 -0
  43. package/src/spriteGpu.ts +271 -0
  44. package/src/spritePass.ts +231 -0
  45. package/src/spriteSheet.ts +147 -0
  46. package/src/spriteTexture.ts +42 -0
  47. package/src/tilemap.ts +114 -0
  48. package/src/uiDraw.ts +63 -0
  49. package/src/uiFocus.ts +80 -0
  50. package/src/uiInput.ts +115 -0
  51. package/src/uiLayout.ts +157 -0
  52. package/src/uiNode.ts +186 -0
package/dist/uiNode.js ADDED
@@ -0,0 +1,79 @@
1
+ /** A node of the retained interface tree: what it is, where it wants to be, and what it draws. */
2
+ function rgba(source) {
3
+ if (source === null || source === undefined)
4
+ return null;
5
+ const out = new Float32Array(4);
6
+ out[0] = source[0];
7
+ out[1] = source[1];
8
+ out[2] = source[2];
9
+ out[3] = (source[3] ?? 1);
10
+ return out;
11
+ }
12
+ export function createUiNode(options = {}) {
13
+ const padding = options.padding ?? 0;
14
+ return {
15
+ children: [],
16
+ parent: null,
17
+ direction: options.direction ?? 'column',
18
+ width: options.width ?? 'fit',
19
+ height: options.height ?? 'fit',
20
+ paddingLeft: padding,
21
+ paddingTop: padding,
22
+ paddingRight: padding,
23
+ paddingBottom: padding,
24
+ gap: options.gap ?? 0,
25
+ align: options.align ?? 'start',
26
+ justify: options.justify ?? 'start',
27
+ absolute: options.absolute ?? false,
28
+ x: options.x ?? 0,
29
+ y: options.y ?? 0,
30
+ contentWidth: options.contentWidth ?? 0,
31
+ contentHeight: options.contentHeight ?? 0,
32
+ hidden: options.hidden ?? false,
33
+ rect: { x: 0, y: 0, w: 0, h: 0 },
34
+ measuredWidth: 0,
35
+ measuredHeight: 0,
36
+ background: rgba(options.background),
37
+ texture: options.texture ?? -1,
38
+ frame: options.frame ?? null,
39
+ tint: rgba(options.tint),
40
+ text: options.text ?? '',
41
+ interactive: options.interactive ?? false,
42
+ focusable: options.focusable ?? false,
43
+ hovered: false,
44
+ pressed: false,
45
+ name: options.name ?? '',
46
+ };
47
+ }
48
+ /** Put `child` at the end of `parent`'s children, detaching it from wherever it was. */
49
+ export function addUiChild(parent, child) {
50
+ if (child.parent !== null)
51
+ removeUiChild(child.parent, child);
52
+ child.parent = parent;
53
+ parent.children.push(child);
54
+ return child;
55
+ }
56
+ /** Take `child` out of `parent`. A child that is not there is left alone. */
57
+ export function removeUiChild(parent, child) {
58
+ const at = parent.children.indexOf(child);
59
+ if (at < 0)
60
+ return;
61
+ parent.children.splice(at, 1);
62
+ child.parent = null;
63
+ }
64
+ /** The node named, depth-first from `root`, or `null`. For a caller finding its own tree again. */
65
+ export function uiNodeNamed(root, name) {
66
+ if (root.name === name)
67
+ return root;
68
+ for (const child of root.children) {
69
+ const hit = uiNodeNamed(child, name);
70
+ if (hit !== null)
71
+ return hit;
72
+ }
73
+ return null;
74
+ }
75
+ /** Whether a point is inside a node's resolved box. */
76
+ export function uiRectHolds(node, x, y) {
77
+ const r = node.rect;
78
+ return x >= r.x && x < r.x + r.w && y >= r.y && y < r.y + r.h;
79
+ }
package/package.json ADDED
@@ -0,0 +1,57 @@
1
+ {
2
+ "name": "@driftengine/ui2d",
3
+ "version": "3.61.0",
4
+ "description": "The 2D layer: batched sprites, sheets, tilemaps, and a retained interface tree over them",
5
+ "license": "Apache-2.0",
6
+ "type": "module",
7
+ "main": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "drift-source": "./src/index.ts",
12
+ "types": "./dist/index.d.ts",
13
+ "default": "./dist/index.js"
14
+ },
15
+ "./package.json": "./package.json",
16
+ "./*": "./*"
17
+ },
18
+ "files": [
19
+ "dist",
20
+ "src",
21
+ "!src/**/*.test.ts",
22
+ "!src/**/*.test.mjs",
23
+ "!src/**/__snapshots__",
24
+ "README.md",
25
+ "LICENSE",
26
+ "NOTICE"
27
+ ],
28
+ "sideEffects": false,
29
+ "peerDependencies": {
30
+ "@driftengine/core": "3.61.0"
31
+ },
32
+ "author": "Drift Technologies",
33
+ "repository": {
34
+ "type": "git",
35
+ "url": "git+https://github.com/drftrun/driftengine.git",
36
+ "directory": "packages/ui2d"
37
+ },
38
+ "homepage": "https://github.com/drftrun/driftengine#readme",
39
+ "bugs": "https://github.com/drftrun/driftengine/issues",
40
+ "keywords": [
41
+ "driftengine",
42
+ "3d",
43
+ "webgl",
44
+ "webgpu",
45
+ "typescript",
46
+ "sprites",
47
+ "tilemap",
48
+ "user-interface",
49
+ "layout"
50
+ ],
51
+ "engines": {
52
+ "node": ">=22.12.0"
53
+ },
54
+ "publishConfig": {
55
+ "access": "public"
56
+ }
57
+ }
@@ -0,0 +1,88 @@
1
+ /** The affine that takes a point in a 2D world, or a CSS pixel, to normalised device coordinates. */
2
+
3
+ /**
4
+ * Six numbers, laid out the way a 2D graphics API has laid them out for thirty years:
5
+ *
6
+ * ```
7
+ * ndc.x = m[0] * x + m[2] * y + m[4]
8
+ * ndc.y = m[1] * x + m[3] * y + m[5]
9
+ * ```
10
+ *
11
+ * A `Float32Array` rather than six fields because it is uploaded as two `vec4`s and read by the
12
+ * vertex stage every frame; an object would be repacked on the way.
13
+ */
14
+ export type Affine2D = Float32Array;
15
+
16
+ export function createAffine2D(): Affine2D {
17
+ return new Float32Array(6);
18
+ }
19
+
20
+ /** A 2D camera: where it is, how far in, and which way up. */
21
+ export interface Camera2D {
22
+ /** World units. The point that lands on the middle of the viewport. */
23
+ readonly x: number;
24
+ readonly y: number;
25
+ /**
26
+ * Pixels per world unit, and it is the same on both axes on purpose.
27
+ *
28
+ * A separate x and y zoom is a non-uniform scale, which turns a circle into an ellipse and a
29
+ * square tile into a rectangle — the caller who wants that has asked for a stretched picture and
30
+ * can say so in the sprite's own size, where it is visible.
31
+ */
32
+ readonly zoom: number;
33
+ /** Radians, anticlockwise. The world turns the other way; see `worldToNdc`. */
34
+ readonly rotation?: number;
35
+ }
36
+
37
+ /**
38
+ * The affine for a 2D world seen through a camera.
39
+ *
40
+ * **y counts upward**, which is the opposite of `screenToNdc` below and is deliberate: a 2D world is
41
+ * a world, and a caller placing a platform above a floor should not have to subtract. A screen-space
42
+ * overlay is the other convention because that is the one every layout system already uses, and the
43
+ * two are different enough that sharing one would make each caller wrong half the time.
44
+ *
45
+ * Rotation turns the *camera*, so the world turns the other way: at `rotation = +π/2` the camera has
46
+ * tilted its head anticlockwise and what was to its right is now above it.
47
+ */
48
+ export function worldToNdc(
49
+ camera: Camera2D,
50
+ viewportWidth: number,
51
+ viewportHeight: number,
52
+ out: Affine2D,
53
+ ): Affine2D {
54
+ const rotation = camera.rotation ?? 0;
55
+ const cos = Math.cos(rotation);
56
+ const sin = Math.sin(rotation);
57
+ const perPixelX = 2 / viewportWidth;
58
+ const perPixelY = 2 / viewportHeight;
59
+ const a = camera.zoom * cos * perPixelX;
60
+ const c = camera.zoom * sin * perPixelX;
61
+ const b = -camera.zoom * sin * perPixelY;
62
+ const d = camera.zoom * cos * perPixelY;
63
+ out[0] = a;
64
+ out[1] = b;
65
+ out[2] = c;
66
+ out[3] = d;
67
+ out[4] = -(a * camera.x + c * camera.y);
68
+ out[5] = -(b * camera.x + d * camera.y);
69
+ return out;
70
+ }
71
+
72
+ /**
73
+ * The affine for CSS pixels with a top-left origin — the convention `InsetRect` and `fillPanel`
74
+ * already use, and the one a caller laying out an overlay already has.
75
+ */
76
+ export function screenToNdc(
77
+ viewportWidth: number,
78
+ viewportHeight: number,
79
+ out: Affine2D,
80
+ ): Affine2D {
81
+ out[0] = 2 / viewportWidth;
82
+ out[1] = 0;
83
+ out[2] = 0;
84
+ out[3] = -2 / viewportHeight;
85
+ out[4] = -1;
86
+ out[5] = 1;
87
+ return out;
88
+ }
package/src/index.ts ADDED
@@ -0,0 +1,55 @@
1
+ /*! DriftEngine | Copyright 2026 Drift Technologies | Apache-2.0 | https://github.com/drftrun/driftengine */
2
+ /**
3
+ * `@driftengine/ui2d` — the 2D layer.
4
+ *
5
+ * A quad is the whole of 2D, so this package is one batch and one pass over it: sprites, the
6
+ * sheets they are cut from, the tilemaps that place thousands of them, and the interface tree that
7
+ * lays a screen out. It draws through `registerPass` rather than through a verb on the renderer,
8
+ * which is what lets it be a package at all — see `pass.ts` in core, which names this package as
9
+ * one of the three that mechanism exists for.
10
+ */
11
+
12
+ export { createAffine2D, screenToNdc, worldToNdc } from './camera2d.ts';
13
+ export type { Affine2D, Camera2D } from './camera2d.ts';
14
+
15
+ export {
16
+ SPRITE_FLOATS,
17
+ createSpriteBatch,
18
+ drawSprite,
19
+ resetSpriteBatch,
20
+ spriteRun,
21
+ } from './spriteBatch.ts';
22
+ export type { SpriteBatch, SpritePlacement, SpriteRun, UvRect } from './spriteBatch.ts';
23
+
24
+ export { DEFAULT_SPRITE_CAPACITY, DEFAULT_SPRITE_SLOTS, createSpritePass } from './spritePass.ts';
25
+ export type { SpritePass, SpritePassOptions } from './spritePass.ts';
26
+
27
+ export { DEFAULT_SPRITE_TEXTURE_OPTIONS } from './spriteTexture.ts';
28
+ export type { SpriteImage, SpriteTextureOptions } from './spriteTexture.ts';
29
+
30
+ export {
31
+ createSpriteFrame,
32
+ frameOf,
33
+ gridSheet,
34
+ namedSheet,
35
+ sheetFrame,
36
+ sheetFrameHeight,
37
+ sheetFrameWidth,
38
+ } from './spriteSheet.ts';
39
+ export type { SheetEntry, SpriteFrame, SpriteSheet } from './spriteSheet.ts';
40
+
41
+ export { TILE_EMPTY, createTilemap, drawTilemap, setTile, tileAt } from './tilemap.ts';
42
+ export type { Tilemap, ViewRect } from './tilemap.ts';
43
+
44
+ export { addUiChild, createUiNode, removeUiChild, uiNodeNamed, uiRectHolds } from './uiNode.ts';
45
+ export type { UiAlign, UiJustify, UiNode, UiNodeOptions, UiRect, UiSize } from './uiNode.ts';
46
+
47
+ export { layoutUiTree } from './uiLayout.ts';
48
+
49
+ export { drawUiTree } from './uiDraw.ts';
50
+ export type { UiContentSink } from './uiDraw.ts';
51
+
52
+ export { uiFocusNext, uiFocusOrder, uiFocusPrevious, uiHitTest } from './uiFocus.ts';
53
+
54
+ export { createUiInput, resetUiInput, routeUiKey, routeUiPointer, setUiFocus } from './uiInput.ts';
55
+ export type { UiInput } from './uiInput.ts';
@@ -0,0 +1,63 @@
1
+ /*
2
+ * Generated from ../sprite.ts by `npm run wgsl`. Do not edit.
3
+ *
4
+ * The GLSL beside this file is the source of truth. A hand edit here is discarded by
5
+ * the next generation, and `npm run wgsl:check` fails the build when this is stale.
6
+ */
7
+
8
+ export const SPRITE_FRAG_WGSL = "struct Uniforms {\n uOutputTransform: i32,\n uOutputExposure: f32,\n}\n\n@group(0) @binding(1) \nvar<uniform> unnamed: Uniforms;\n@group(0) @binding(32) \nvar uSpriteTexture_t: texture_2d<f32>;\n@group(0) @binding(33) \nvar uSpriteTexture_s: sampler;\nvar<private> vUv_1: vec2<f32>;\nvar<private> vTint_1: vec4<f32>;\nvar<private> fragColor: vec4<f32>;\n\nfn linearToSrgb_u0028_vf3_u003b(c: ptr<function, vec3<f32>>) -> vec3<f32> {\n var low: vec3<f32>;\n var high: vec3<f32>;\n\n let _e55 = (*c);\n low = (_e55 * 12.92f);\n let _e57 = (*c);\n high = ((pow(max(_e57, vec3<f32>(0f, 0f, 0f)), vec3<f32>(0.41666666f, 0.41666666f, 0.41666666f)) * 1.055f) - vec3(0.055f));\n let _e63 = high;\n let _e64 = low;\n let _e65 = (*c);\n return mix(_e63, _e64, step(_e65, vec3<f32>(0.0031308f, 0.0031308f, 0.0031308f)));\n}\n\nfn rrtAndOdtFit_u0028_vf3_u003b(v: ptr<function, vec3<f32>>) -> vec3<f32> {\n var a: vec3<f32>;\n var b: vec3<f32>;\n\n let _e55 = (*v);\n let _e56 = (*v);\n a = ((_e55 * (_e56 + vec3(0.0245786f))) - vec3(0.000090537f));\n let _e62 = (*v);\n let _e63 = (*v);\n b = ((_e62 * ((_e63 * 0.983729f) + vec3(0.432951f))) + vec3(0.238081f));\n let _e70 = a;\n let _e71 = b;\n return (_e70 / _e71);\n}\n\nfn acesFilmic_u0028_vf3_u003b(x: ptr<function, vec3<f32>>) -> vec3<f32> {\n var param: vec3<f32>;\n\n let _e55 = unnamed.uOutputExposure;\n let _e56 = (*x);\n (*x) = (_e56 * _e55);\n let _e58 = (*x);\n param = (mat3x3<f32>(vec3<f32>(0.59719f, 0.076f, 0.0284f), vec3<f32>(0.35458f, 0.90834f, 0.13383f), vec3<f32>(0.04823f, 0.01566f, 0.83777f)) * _e58);\n let _e60 = rrtAndOdtFit_u0028_vf3_u003b((&param));\n return clamp((mat3x3<f32>(vec3<f32>(1.60475f, -0.10208f, -0.00327f), vec3<f32>(-0.53108f, 1.10813f, -0.07276f), vec3<f32>(-0.07367f, -0.00605f, 1.07602f)) * _e60), vec3(0f), vec3(1f));\n}\n\nfn applyOutputTransform_u0028_vf3_u003b(c_1: ptr<function, vec3<f32>>) -> vec3<f32> {\n var param_1: vec3<f32>;\n var param_2: vec3<f32>;\n\n let _e56 = unnamed.uOutputTransform;\n if (_e56 == 0i) {\n let _e58 = (*c_1);\n return _e58;\n }\n let _e60 = unnamed.uOutputTransform;\n if (_e60 == 2i) {\n let _e62 = (*c_1);\n param_1 = _e62;\n let _e63 = acesFilmic_u0028_vf3_u003b((&param_1));\n (*c_1) = _e63;\n }\n let _e64 = (*c_1);\n param_2 = _e64;\n let _e65 = linearToSrgb_u0028_vf3_u003b((&param_2));\n return _e65;\n}\n\nfn main_1() {\n var texel: vec4<f32>;\n var colour: vec4<f32>;\n var param_3: vec3<f32>;\n\n let _e55 = vUv_1;\n let _e56 = textureSample(uSpriteTexture_t, uSpriteTexture_s, _e55);\n texel = _e56;\n let _e57 = texel;\n let _e58 = vTint_1;\n colour = (_e57 * _e58);\n let _e61 = colour[3u];\n if (_e61 < 0.00392157f) {\n discard;\n }\n let _e63 = colour;\n param_3 = _e63.xyz;\n let _e65 = applyOutputTransform_u0028_vf3_u003b((&param_3));\n let _e66 = colour;\n colour = vec4<f32>(_e65.x, _e65.y, _e65.z, _e66.w);\n let _e72 = colour;\n let _e75 = colour[3u];\n let _e76 = (_e72.xyz * _e75);\n let _e78 = colour[3u];\n fragColor = vec4<f32>(_e76.x, _e76.y, _e76.z, _e78);\n return;\n}\n\n@fragment \nfn main(@location(0) vUv: vec2<f32>, @location(1) vTint: vec4<f32>) -> @location(0) vec4<f32> {\n vUv_1 = vUv;\n vTint_1 = vTint;\n main_1();\n let _e5 = fragColor;\n return _e5;\n}\n";
9
+
10
+ export const SPRITE_VERT_WGSL = "struct Uniforms {\n uToNdc0_: vec4<f32>,\n uToNdc1_: vec4<f32>,\n uClipCorrection: mat4x4<f32>,\n}\n\nstruct gl_PerVertex {\n @builtin(position) gl_Position: vec4<f32>,\n gl_PointSize: f32,\n}\n\nstruct VertexOutput {\n @location(0) member: vec2<f32>,\n @location(1) member_1: vec4<f32>,\n @builtin(position) gl_Position: vec4<f32>,\n}\n\nvar<private> gl_VertexIndex_1: i32;\nvar<private> aOrigin_1: vec2<f32>;\nvar<private> aEdges_1: vec4<f32>;\n@group(0) @binding(0) \nvar<uniform> unnamed: Uniforms;\nvar<private> vUv: vec2<f32>;\nvar<private> aUv_1: vec4<f32>;\nvar<private> vTint: vec4<f32>;\nvar<private> aTint_1: vec4<f32>;\nvar<private> unnamed_1: gl_PerVertex = gl_PerVertex(vec4<f32>(0f, 0f, 0f, 1f), 1f);\n\nfn main_1() {\n var corner: vec2<f32>;\n var indexable: array<vec2<f32>, 6>;\n var p: vec2<f32>;\n var ndc: vec2<f32>;\n\n let _e28 = gl_VertexIndex_1;\n indexable = array<vec2<f32>, 6>(vec2<f32>(0f, 0f), vec2<f32>(1f, 0f), vec2<f32>(0f, 1f), vec2<f32>(0f, 1f), vec2<f32>(1f, 0f), vec2<f32>(1f, 1f));\n let _e30 = indexable[_e28];\n corner = _e30;\n let _e31 = aOrigin_1;\n let _e32 = aEdges_1;\n let _e35 = corner[0u];\n let _e38 = aEdges_1;\n let _e41 = corner[1u];\n p = ((_e31 + (_e32.xy * _e35)) + (_e38.zw * _e41));\n let _e46 = unnamed.uToNdc0_[0u];\n let _e48 = p[0u];\n let _e52 = unnamed.uToNdc0_[2u];\n let _e54 = p[1u];\n let _e59 = unnamed.uToNdc1_[0u];\n let _e63 = unnamed.uToNdc0_[1u];\n let _e65 = p[0u];\n let _e69 = unnamed.uToNdc0_[3u];\n let _e71 = p[1u];\n let _e76 = unnamed.uToNdc1_[1u];\n ndc = vec2<f32>((((_e46 * _e48) + (_e52 * _e54)) + _e59), (((_e63 * _e65) + (_e69 * _e71)) + _e76));\n let _e79 = aUv_1;\n let _e81 = aUv_1;\n let _e83 = corner;\n vUv = mix(_e79.xy, _e81.zw, _e83);\n let _e85 = aTint_1;\n vTint = _e85;\n let _e87 = unnamed.uClipCorrection;\n let _e88 = ndc;\n unnamed_1.gl_Position = (_e87 * vec4<f32>(_e88.x, _e88.y, 0f, 1f));\n return;\n}\n\n@vertex \nfn main(@builtin(vertex_index) gl_VertexIndex: u32, @location(3) aOrigin: vec2<f32>, @location(0) aEdges: vec4<f32>, @location(1) aUv: vec4<f32>, @location(2) aTint: vec4<f32>) -> VertexOutput {\n gl_VertexIndex_1 = i32(gl_VertexIndex);\n aOrigin_1 = aOrigin;\n aEdges_1 = aEdges;\n aUv_1 = aUv;\n aTint_1 = aTint;\n main_1();\n let _e16 = unnamed_1.gl_Position.y;\n unnamed_1.gl_Position.y = -(_e16);\n let _e18 = vUv;\n let _e19 = vTint;\n let _e20 = unnamed_1.gl_Position;\n return VertexOutput(_e18, _e19, _e20);\n}\n";
11
+
12
+ /**
13
+ * What the transform assigned, so the renderer binds the same numbers.
14
+ *
15
+ * A permuted shader has one entry per variant, keyed as `FLAT_FRAG_WGSL` is.
16
+ */
17
+ export const SPRITE_BINDINGS = {
18
+ "SPRITE_FRAG": {
19
+ "uniforms": 1,
20
+ "uniformSize": 16,
21
+ "fields": {
22
+ "uOutputTransform": {
23
+ "offset": 0,
24
+ "size": 4,
25
+ "type": "int"
26
+ },
27
+ "uOutputExposure": {
28
+ "offset": 4,
29
+ "size": 4,
30
+ "type": "float"
31
+ }
32
+ },
33
+ "textures": {
34
+ "uSpriteTexture": {
35
+ "texture": 32,
36
+ "sampler": 33,
37
+ "type": "sampler2D"
38
+ }
39
+ }
40
+ },
41
+ "SPRITE_VERT": {
42
+ "uniforms": 0,
43
+ "uniformSize": 96,
44
+ "fields": {
45
+ "uToNdc0": {
46
+ "offset": 0,
47
+ "size": 16,
48
+ "type": "vec4"
49
+ },
50
+ "uToNdc1": {
51
+ "offset": 16,
52
+ "size": 16,
53
+ "type": "vec4"
54
+ },
55
+ "uClipCorrection": {
56
+ "offset": 32,
57
+ "size": 64,
58
+ "type": "mat4"
59
+ }
60
+ },
61
+ "textures": {}
62
+ }
63
+ } as const;
@@ -0,0 +1,102 @@
1
+ /** The sprite program: one textured, tinted quad per instance, blended in submission order. */
2
+
3
+ import { OUTPUT_TRANSFORM_GLSL } from '@driftengine/core';
4
+
5
+ /**
6
+ * Below this the fragment is thrown away rather than blended.
7
+ *
8
+ * Half of one eight-bit step. A sprite sheet's transparent margin is exactly zero and costs
9
+ * nothing to reject; what this buys is the *soft* edge of an antialiased glyph or a feathered
10
+ * particle, whose outermost ring blends a texture fetch and a blend for a contribution no frame
11
+ * can show. It is a fill saving and not a correctness rule, which is why it is this low: a cutout
12
+ * threshold that shaved visible alpha would put a hard edge on every soft one.
13
+ */
14
+ const ALPHA_FLOOR = 1.0 / 255.0;
15
+
16
+ export const SPRITE_VERT = `#version 300 es
17
+
18
+ /**
19
+ * The two edge vectors of the quad, in the space the affine below maps from.
20
+ *
21
+ * Edge vectors rather than a size and an angle: the rotation is resolved on the CPU once per
22
+ * sprite, where it costs one sine, and the vertex stage does two multiplies and an add. A sprite
23
+ * batch is vertex-bound at four thousand quads and this is the whole of its vertex work.
24
+ */
25
+ layout(location = 0) in vec4 aEdges;
26
+ /** The frame this sprite reads, as (u0, v0) and (u1, v1). */
27
+ layout(location = 1) in vec4 aUv;
28
+ /** Straight, not premultiplied. The fragment stage premultiplies after the texture fetch. */
29
+ layout(location = 2) in vec4 aTint;
30
+ /** The corner the two edges grow from. */
31
+ layout(location = 3) in vec2 aOrigin;
32
+
33
+ /**
34
+ * The affine to normalised device coordinates, as (a, b, c, d) and (e, f).
35
+ *
36
+ * Two \`vec4\`s rather than a \`mat3\`, because a \`mat3\` in a uniform block is three
37
+ * sixteen-byte rows for nine useful floats and this is read once per vertex.
38
+ */
39
+ uniform vec4 uToNdc0;
40
+ uniform vec4 uToNdc1;
41
+ /**
42
+ * Clip space, as the backend drawing this defines it. Identity on WebGL2.
43
+ *
44
+ * The same correction \`panel.ts\` carries and for the same reason: this stage builds its own clip
45
+ * position and never multiplies by a camera, so the generated vertex shader's Y negation would
46
+ * stand uncancelled and the whole 2D layer would land mirrored about the middle of the frame.
47
+ */
48
+ uniform mat4 uClipCorrection;
49
+
50
+ out vec2 vUv;
51
+ out vec4 vTint;
52
+
53
+ /**
54
+ * Two triangles, wound so that either winding draws: this pass culls nothing.
55
+ *
56
+ * A sprite is flipped by giving it a negative width, which reverses the winding — so a cull mode
57
+ * would silently drop every mirrored sprite, which is what a character facing left is.
58
+ */
59
+ const vec2 CORNERS[6] = vec2[6](
60
+ vec2(0.0, 0.0), vec2(1.0, 0.0), vec2(0.0, 1.0),
61
+ vec2(0.0, 1.0), vec2(1.0, 0.0), vec2(1.0, 1.0)
62
+ );
63
+
64
+ void main() {
65
+ vec2 corner = CORNERS[gl_VertexID];
66
+ vec2 p = aOrigin + aEdges.xy * corner.x + aEdges.zw * corner.y;
67
+ vec2 ndc = vec2(
68
+ uToNdc0.x * p.x + uToNdc0.z * p.y + uToNdc1.x,
69
+ uToNdc0.y * p.x + uToNdc0.w * p.y + uToNdc1.y
70
+ );
71
+ vUv = mix(aUv.xy, aUv.zw, corner);
72
+ vTint = aTint;
73
+ /* z is 0 and nothing depth-tests here: the order sprites were submitted in is the layering. */
74
+ gl_Position = uClipCorrection * vec4(ndc, 0.0, 1.0);
75
+ }
76
+ `;
77
+
78
+ export const SPRITE_FRAG = `#version 300 es
79
+ precision highp float;
80
+
81
+ uniform sampler2D uSpriteTexture;
82
+
83
+ in vec2 vUv;
84
+ in vec4 vTint;
85
+ out vec4 fragColor;
86
+
87
+ ${OUTPUT_TRANSFORM_GLSL}
88
+
89
+ void main() {
90
+ /*
91
+ * No branch reaches this fetch, so the implicit derivative is taken in uniform control flow and
92
+ * a sheet may be mipmapped. The 2026-08-07 rule is about a sample under a branch and there is
93
+ * none here; adding one later would mean a \`textureLod\`.
94
+ */
95
+ vec4 texel = texture(uSpriteTexture, vUv);
96
+ vec4 colour = texel * vTint;
97
+ if (colour.a < ${ALPHA_FLOOR.toFixed(8)}) discard;
98
+ colour.rgb = applyOutputTransform(colour.rgb);
99
+ /* Premultiplied out, because the blend is (ONE, ONE_MINUS_SRC_ALPHA). */
100
+ fragColor = vec4(colour.rgb * colour.a, colour.a);
101
+ }
102
+ `;
@@ -0,0 +1,169 @@
1
+ /** The CPU side of a sprite draw: quads packed into one instance buffer, in submission order. */
2
+
3
+ /**
4
+ * Floats per instance: two edge vectors, a UV rectangle, a tint, and an origin.
5
+ *
6
+ * Fourteen rather than a rounder sixteen because a vertex buffer's stride only has to be a multiple
7
+ * of four bytes, and two floats a sprite is 8 KB at a four-thousand-sprite batch.
8
+ */
9
+ export const SPRITE_FLOATS = 14;
10
+
11
+ /** Where a sprite goes, in whatever space the batch is being drawn in. */
12
+ export interface SpritePlacement {
13
+ /**
14
+ * The corner the sprite grows from, and *which* corner depends on the affine.
15
+ *
16
+ * In screen space y counts down, so this is the top-left; in a 2D world y counts up, so it is
17
+ * the bottom-left. The batch does not know which it is in and does not need to: it is the same
18
+ * arithmetic either way, and `camera2d.ts` is where the two conventions are written down.
19
+ */
20
+ readonly x: number;
21
+ readonly y: number;
22
+ readonly w: number;
23
+ readonly h: number;
24
+ /**
25
+ * Radians, anticlockwise in the mathematical sense — which reads as clockwise on screen, where y
26
+ * counts down. Defaults to none.
27
+ */
28
+ readonly rotation?: number;
29
+ /** The point rotation turns about, as a fraction of the sprite. Defaults to its centre. */
30
+ readonly pivotX?: number;
31
+ readonly pivotY?: number;
32
+ }
33
+
34
+ /** A rectangle of a texture, in the 0..1 the sampler reads. See `spriteSheet.ts`. */
35
+ export interface UvRect {
36
+ readonly u0: number;
37
+ readonly v0: number;
38
+ readonly u1: number;
39
+ readonly v1: number;
40
+ }
41
+
42
+ /**
43
+ * A run of consecutive instances that share a texture.
44
+ *
45
+ * Runs exist because the number to hold down is material changes rather than draws: a tilemap over
46
+ * one sheet is one run however many thousand tiles it is, and a run boundary is the only place the
47
+ * pass has to touch the GPU between them.
48
+ */
49
+ export interface SpriteRun {
50
+ readonly texture: number;
51
+ readonly first: number;
52
+ readonly count: number;
53
+ }
54
+
55
+ /**
56
+ * One frame's worth of sprites.
57
+ *
58
+ * Fixed capacity, filled from the front, reset each frame. It grows for nobody: a batch that
59
+ * reallocated mid-frame would allocate in the hot path, and the failure it is protecting against —
60
+ * a caller drawing more than it planned for — is one that wants counting rather than absorbing.
61
+ */
62
+ export interface SpriteBatch {
63
+ /** `capacity * SPRITE_FLOATS`, filled to `count * SPRITE_FLOATS`. */
64
+ readonly instances: Float32Array;
65
+ readonly capacity: number;
66
+ count: number;
67
+ /** Three entries a run: texture, first instance, length. */
68
+ readonly runs: Int32Array;
69
+ runCount: number;
70
+ /** Sprites this frame refused for want of room. Zero is the only good value. */
71
+ dropped: number;
72
+ }
73
+
74
+ export function createSpriteBatch(capacity: number): SpriteBatch {
75
+ return {
76
+ instances: new Float32Array(capacity * SPRITE_FLOATS),
77
+ capacity,
78
+ count: 0,
79
+ /*
80
+ * One run per sprite is the worst case — a caller alternating textures every draw — and
81
+ * allocating for it costs twelve bytes a sprite against the fifty-six the sprite itself costs.
82
+ * The alternative is a second capacity to overflow, on a path where overflowing means dropping
83
+ * a draw the caller can see.
84
+ */
85
+ runs: new Int32Array(capacity * 3),
86
+ runCount: 0,
87
+ dropped: 0,
88
+ };
89
+ }
90
+
91
+ export function resetSpriteBatch(batch: SpriteBatch): void {
92
+ batch.count = 0;
93
+ batch.runCount = 0;
94
+ batch.dropped = 0;
95
+ }
96
+
97
+ /** Read a run back. For tests and diagnostics; the pass reads `runs` directly. */
98
+ export function spriteRun(batch: SpriteBatch, index: number): SpriteRun {
99
+ const at = index * 3;
100
+ return {
101
+ texture: batch.runs[at] as number,
102
+ first: batch.runs[at + 1] as number,
103
+ count: batch.runs[at + 2] as number,
104
+ };
105
+ }
106
+
107
+ /**
108
+ * Push one quad. Allocates nothing.
109
+ *
110
+ * `source` of `null` is the whole texture and `tint` of `null` is opaque white, because those are
111
+ * what a caller drawing a plain image wants and neither should cost an object per draw.
112
+ */
113
+ export function drawSprite(
114
+ batch: SpriteBatch,
115
+ texture: number,
116
+ placement: SpritePlacement,
117
+ source: UvRect | null,
118
+ tint: ArrayLike<number> | null,
119
+ ): void {
120
+ if (batch.count >= batch.capacity) {
121
+ batch.dropped += 1;
122
+ return;
123
+ }
124
+
125
+ const rotation = placement.rotation ?? 0;
126
+ const cos = rotation === 0 ? 1 : Math.cos(rotation);
127
+ const sin = rotation === 0 ? 0 : Math.sin(rotation);
128
+ const w = placement.w;
129
+ const h = placement.h;
130
+ // The two edge vectors of the quad, turned.
131
+ const ax = w * cos;
132
+ const ay = w * sin;
133
+ const bx = -h * sin;
134
+ const by = h * cos;
135
+ const pivotX = placement.pivotX ?? 0.5;
136
+ const pivotY = placement.pivotY ?? 0.5;
137
+ // The pivot does not move, so the origin is wherever it has to be for that to hold.
138
+ const originX = placement.x + w * pivotX - (ax * pivotX + bx * pivotY);
139
+ const originY = placement.y + h * pivotY - (ay * pivotX + by * pivotY);
140
+
141
+ const at = batch.count * SPRITE_FLOATS;
142
+ const f = batch.instances;
143
+ f[at] = ax;
144
+ f[at + 1] = ay;
145
+ f[at + 2] = bx;
146
+ f[at + 3] = by;
147
+ f[at + 4] = source === null ? 0 : source.u0;
148
+ f[at + 5] = source === null ? 0 : source.v0;
149
+ f[at + 6] = source === null ? 1 : source.u1;
150
+ f[at + 7] = source === null ? 1 : source.v1;
151
+ f[at + 8] = tint === null ? 1 : (tint[0] as number);
152
+ f[at + 9] = tint === null ? 1 : (tint[1] as number);
153
+ f[at + 10] = tint === null ? 1 : (tint[2] as number);
154
+ f[at + 11] = tint === null ? 1 : (tint[3] as number);
155
+ f[at + 12] = originX;
156
+ f[at + 13] = originY;
157
+
158
+ const lastRun = (batch.runCount - 1) * 3;
159
+ if (batch.runCount > 0 && batch.runs[lastRun] === texture) {
160
+ batch.runs[lastRun + 2] = (batch.runs[lastRun + 2] as number) + 1;
161
+ } else {
162
+ const run = batch.runCount * 3;
163
+ batch.runs[run] = texture;
164
+ batch.runs[run + 1] = batch.count;
165
+ batch.runs[run + 2] = 1;
166
+ batch.runCount += 1;
167
+ }
168
+ batch.count += 1;
169
+ }