@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.
- package/LICENSE +202 -0
- package/NOTICE +9 -0
- package/README.md +228 -0
- package/dist/camera2d.d.ts +47 -0
- package/dist/camera2d.js +46 -0
- package/dist/index.d.ts +30 -0
- package/dist/index.js +21 -0
- package/dist/shaders/generated/sprite.wgsl.d.ts +54 -0
- package/dist/shaders/generated/sprite.wgsl.js +60 -0
- package/dist/shaders/sprite.d.ts +3 -0
- package/dist/shaders/sprite.js +98 -0
- package/dist/spriteBatch.d.ts +78 -0
- package/dist/spriteBatch.js +93 -0
- package/dist/spriteGl.d.ts +26 -0
- package/dist/spriteGl.js +207 -0
- package/dist/spriteGpu.d.ts +31 -0
- package/dist/spriteGpu.js +201 -0
- package/dist/spritePass.d.ts +54 -0
- package/dist/spritePass.js +142 -0
- package/dist/spriteSheet.d.ts +64 -0
- package/dist/spriteSheet.js +84 -0
- package/dist/spriteTexture.d.ts +36 -0
- package/dist/spriteTexture.js +5 -0
- package/dist/tilemap.d.ts +47 -0
- package/dist/tilemap.js +70 -0
- package/dist/uiDraw.d.ts +28 -0
- package/dist/uiDraw.js +41 -0
- package/dist/uiFocus.d.ts +36 -0
- package/dist/uiFocus.js +78 -0
- package/dist/uiInput.d.ts +51 -0
- package/dist/uiInput.js +85 -0
- package/dist/uiLayout.d.ts +23 -0
- package/dist/uiLayout.js +144 -0
- package/dist/uiNode.d.ts +105 -0
- package/dist/uiNode.js +79 -0
- package/package.json +57 -0
- package/src/camera2d.ts +88 -0
- package/src/index.ts +55 -0
- package/src/shaders/generated/sprite.wgsl.ts +63 -0
- package/src/shaders/sprite.ts +102 -0
- package/src/spriteBatch.ts +169 -0
- package/src/spriteGl.ts +270 -0
- package/src/spriteGpu.ts +271 -0
- package/src/spritePass.ts +231 -0
- package/src/spriteSheet.ts +147 -0
- package/src/spriteTexture.ts +42 -0
- package/src/tilemap.ts +114 -0
- package/src/uiDraw.ts +63 -0
- package/src/uiFocus.ts +80 -0
- package/src/uiInput.ts +115 -0
- package/src/uiLayout.ts +157 -0
- package/src/uiNode.ts +186 -0
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/** The WebGPU half of the sprite pass: one pipeline, one instance buffer, a bind group per slot. */
|
|
2
|
+
import { SPRITE_BINDINGS, SPRITE_FRAG_WGSL, SPRITE_VERT_WGSL, } from './shaders/generated/sprite.wgsl.js';
|
|
3
|
+
import { SPRITE_FLOATS } from './spriteBatch.js';
|
|
4
|
+
const VERT = SPRITE_BINDINGS.SPRITE_VERT;
|
|
5
|
+
const FRAG = SPRITE_BINDINGS.SPRITE_FRAG;
|
|
6
|
+
const STRIDE = SPRITE_FLOATS * 4;
|
|
7
|
+
const STAGE_VERTEX = 1; // GPUShaderStage.VERTEX
|
|
8
|
+
const STAGE_FRAGMENT = 2; // GPUShaderStage.FRAGMENT
|
|
9
|
+
const UNIFORM_COPY_DST = 64 | 8; // GPUBufferUsage.UNIFORM | COPY_DST
|
|
10
|
+
const VERTEX_COPY_DST = 32 | 8; // GPUBufferUsage.VERTEX | COPY_DST
|
|
11
|
+
const TEXTURE_USAGE = 4 | 2 | 16; // TEXTURE_BINDING | COPY_DST | RENDER_ATTACHMENT
|
|
12
|
+
export function createGpuSprites(device, format, depthFormat, samples, capacity, slots, label) {
|
|
13
|
+
const layout = device.createBindGroupLayout({
|
|
14
|
+
label: `${label}.layout`,
|
|
15
|
+
entries: [
|
|
16
|
+
{ binding: VERT.uniforms, visibility: STAGE_VERTEX, buffer: { type: 'uniform' } },
|
|
17
|
+
{ binding: FRAG.uniforms, visibility: STAGE_FRAGMENT, buffer: { type: 'uniform' } },
|
|
18
|
+
{
|
|
19
|
+
binding: FRAG.textures.uSpriteTexture.texture,
|
|
20
|
+
visibility: STAGE_FRAGMENT,
|
|
21
|
+
texture: { sampleType: 'float' },
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
binding: FRAG.textures.uSpriteTexture.sampler,
|
|
25
|
+
visibility: STAGE_FRAGMENT,
|
|
26
|
+
sampler: { type: 'filtering' },
|
|
27
|
+
},
|
|
28
|
+
],
|
|
29
|
+
});
|
|
30
|
+
const pipeline = device.createRenderPipeline({
|
|
31
|
+
label,
|
|
32
|
+
layout: device.createPipelineLayout({ bindGroupLayouts: [layout] }),
|
|
33
|
+
vertex: {
|
|
34
|
+
module: device.createShaderModule({ label: `${label}.vert`, code: SPRITE_VERT_WGSL }),
|
|
35
|
+
entryPoint: 'main',
|
|
36
|
+
buffers: [
|
|
37
|
+
{
|
|
38
|
+
arrayStride: STRIDE,
|
|
39
|
+
stepMode: 'instance',
|
|
40
|
+
attributes: [
|
|
41
|
+
{ shaderLocation: 0, offset: 0, format: 'float32x4' },
|
|
42
|
+
{ shaderLocation: 1, offset: 16, format: 'float32x4' },
|
|
43
|
+
{ shaderLocation: 2, offset: 32, format: 'float32x4' },
|
|
44
|
+
{ shaderLocation: 3, offset: 48, format: 'float32x2' },
|
|
45
|
+
],
|
|
46
|
+
},
|
|
47
|
+
],
|
|
48
|
+
},
|
|
49
|
+
fragment: {
|
|
50
|
+
module: device.createShaderModule({ label: `${label}.frag`, code: SPRITE_FRAG_WGSL }),
|
|
51
|
+
entryPoint: 'main',
|
|
52
|
+
targets: [
|
|
53
|
+
{
|
|
54
|
+
format,
|
|
55
|
+
/* Premultiplied `over`, matching the WebGL2 half exactly. */
|
|
56
|
+
blend: {
|
|
57
|
+
color: { srcFactor: 'one', dstFactor: 'one-minus-src-alpha', operation: 'add' },
|
|
58
|
+
alpha: { srcFactor: 'one', dstFactor: 'one-minus-src-alpha', operation: 'add' },
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
],
|
|
62
|
+
},
|
|
63
|
+
/*
|
|
64
|
+
* Nothing is culled: a sprite is mirrored by giving it a negative width, which reverses its
|
|
65
|
+
* winding, and a character facing left is exactly that.
|
|
66
|
+
*/
|
|
67
|
+
primitive: { topology: 'triangle-list', cullMode: 'none' },
|
|
68
|
+
multisample: { count: samples },
|
|
69
|
+
/*
|
|
70
|
+
* Neither tested nor written. The 2D layer's order *is* its layering — there is no z to sort
|
|
71
|
+
* by — and a pass that depth-tested would let whatever 3D geometry is in the frame punch holes
|
|
72
|
+
* in an overlay drawn over it. Declared all the same, because a pipeline in a render pass that
|
|
73
|
+
* has a depth attachment must name its format.
|
|
74
|
+
*/
|
|
75
|
+
depthStencil: { format: depthFormat, depthWriteEnabled: false, depthCompare: 'always' },
|
|
76
|
+
});
|
|
77
|
+
const vertexUniforms = device.createBuffer({
|
|
78
|
+
label: `${label}.vertexUniforms`,
|
|
79
|
+
size: VERT.uniformSize,
|
|
80
|
+
usage: UNIFORM_COPY_DST,
|
|
81
|
+
});
|
|
82
|
+
const fragmentUniforms = device.createBuffer({
|
|
83
|
+
label: `${label}.fragmentUniforms`,
|
|
84
|
+
size: FRAG.uniformSize,
|
|
85
|
+
usage: UNIFORM_COPY_DST,
|
|
86
|
+
});
|
|
87
|
+
const instances = device.createBuffer({
|
|
88
|
+
label: `${label}.instances`,
|
|
89
|
+
size: capacity * STRIDE,
|
|
90
|
+
usage: VERTEX_COPY_DST,
|
|
91
|
+
});
|
|
92
|
+
const vertexScratch = new ArrayBuffer(VERT.uniformSize);
|
|
93
|
+
const fragmentScratch = new ArrayBuffer(FRAG.uniformSize);
|
|
94
|
+
return {
|
|
95
|
+
pipeline,
|
|
96
|
+
layout,
|
|
97
|
+
vertexUniforms,
|
|
98
|
+
fragmentUniforms,
|
|
99
|
+
instances,
|
|
100
|
+
samplers: {
|
|
101
|
+
nearest: device.createSampler({ label: `${label}.nearest` }),
|
|
102
|
+
linear: device.createSampler({
|
|
103
|
+
label: `${label}.linear`,
|
|
104
|
+
magFilter: 'linear',
|
|
105
|
+
minFilter: 'linear',
|
|
106
|
+
}),
|
|
107
|
+
},
|
|
108
|
+
slots: new Array(slots).fill(null),
|
|
109
|
+
vertexScratch,
|
|
110
|
+
vertexFloats: new Float32Array(vertexScratch),
|
|
111
|
+
fragmentScratch,
|
|
112
|
+
fragmentFloats: new Float32Array(fragmentScratch),
|
|
113
|
+
fragmentInts: new Int32Array(fragmentScratch),
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
export function setGpuSpriteTexture(device, sprites, slot, source, options) {
|
|
117
|
+
const previous = sprites.slots[slot];
|
|
118
|
+
if (previous !== null && previous !== undefined)
|
|
119
|
+
previous.texture.destroy();
|
|
120
|
+
const texture = device.createTexture({
|
|
121
|
+
label: `ui2d.sprite.${slot}`,
|
|
122
|
+
size: [source.width, source.height],
|
|
123
|
+
format: options.colorSpace === 'linear' ? 'rgba8unorm' : 'rgba8unorm-srgb',
|
|
124
|
+
usage: TEXTURE_USAGE,
|
|
125
|
+
});
|
|
126
|
+
/*
|
|
127
|
+
* **No flip, matching WebGL2** — `surfaceTexture.ts` carries the whole argument and the bug it
|
|
128
|
+
* came from: `UNPACK_FLIP_Y_WEBGL` is ignored for an `ImageBitmap` and honoured for a canvas, so
|
|
129
|
+
* a `flipY: true` here would be self-consistent and disagree with the other backend on exactly
|
|
130
|
+
* the source type most callers use.
|
|
131
|
+
*/
|
|
132
|
+
device.queue.copyExternalImageToTexture({ source: source, flipY: false }, { texture }, [source.width, source.height]);
|
|
133
|
+
const sampler = options.filter === 'linear' ? sprites.samplers.linear : sprites.samplers.nearest;
|
|
134
|
+
const bindGroup = device.createBindGroup({
|
|
135
|
+
label: `ui2d.sprite.${slot}.bindGroup`,
|
|
136
|
+
layout: sprites.layout,
|
|
137
|
+
entries: [
|
|
138
|
+
{ binding: VERT.uniforms, resource: { buffer: sprites.vertexUniforms } },
|
|
139
|
+
{ binding: FRAG.uniforms, resource: { buffer: sprites.fragmentUniforms } },
|
|
140
|
+
{ binding: FRAG.textures.uSpriteTexture.texture, resource: texture.createView() },
|
|
141
|
+
{ binding: FRAG.textures.uSpriteTexture.sampler, resource: sampler },
|
|
142
|
+
],
|
|
143
|
+
});
|
|
144
|
+
sprites.slots[slot] = { texture, bindGroup };
|
|
145
|
+
}
|
|
146
|
+
/** The same one white texel, written straight into a texture. See the WebGL2 half for why. */
|
|
147
|
+
export function setGpuWhiteTexture(device, sprites, slot) {
|
|
148
|
+
const texture = device.createTexture({
|
|
149
|
+
label: 'ui2d.sprite.white',
|
|
150
|
+
size: [1, 1],
|
|
151
|
+
/* Not `-srgb`: 255 is 255 either way, and a linear format says so without a decode. */
|
|
152
|
+
format: 'rgba8unorm',
|
|
153
|
+
usage: TEXTURE_USAGE,
|
|
154
|
+
});
|
|
155
|
+
device.queue.writeTexture({ texture }, new Uint8Array([255, 255, 255, 255]), { bytesPerRow: 4 }, [1, 1]);
|
|
156
|
+
const bindGroup = device.createBindGroup({
|
|
157
|
+
label: 'ui2d.sprite.white.bindGroup',
|
|
158
|
+
layout: sprites.layout,
|
|
159
|
+
entries: [
|
|
160
|
+
{ binding: VERT.uniforms, resource: { buffer: sprites.vertexUniforms } },
|
|
161
|
+
{ binding: FRAG.uniforms, resource: { buffer: sprites.fragmentUniforms } },
|
|
162
|
+
{ binding: FRAG.textures.uSpriteTexture.texture, resource: texture.createView() },
|
|
163
|
+
{ binding: FRAG.textures.uSpriteTexture.sampler, resource: sprites.samplers.nearest },
|
|
164
|
+
],
|
|
165
|
+
});
|
|
166
|
+
sprites.slots[slot] = { texture, bindGroup };
|
|
167
|
+
}
|
|
168
|
+
export function uploadGpuInstances(device, sprites, batch) {
|
|
169
|
+
if (batch.count === 0)
|
|
170
|
+
return;
|
|
171
|
+
device.queue.writeBuffer(sprites.instances, 0, batch.instances.buffer, batch.instances.byteOffset, batch.count * STRIDE);
|
|
172
|
+
}
|
|
173
|
+
export function drawGpuSprites(pass, sprites, batch) {
|
|
174
|
+
if (batch.count === 0)
|
|
175
|
+
return;
|
|
176
|
+
pass.setPipeline(sprites.pipeline);
|
|
177
|
+
for (let run = 0; run < batch.runCount; run += 1) {
|
|
178
|
+
const at = run * 3;
|
|
179
|
+
const slot = sprites.slots[batch.runs[at]];
|
|
180
|
+
if (slot === null || slot === undefined)
|
|
181
|
+
continue;
|
|
182
|
+
const first = batch.runs[at + 1];
|
|
183
|
+
const count = batch.runs[at + 2];
|
|
184
|
+
pass.setBindGroup(0, slot.bindGroup);
|
|
185
|
+
/*
|
|
186
|
+
* The run's offset goes into the vertex buffer binding rather than into `firstInstance`,
|
|
187
|
+
* which is the one place the two backends can be made to do the same arithmetic: WebGL2 has
|
|
188
|
+
* no base-instance call at all and re-points its attributes the same way.
|
|
189
|
+
*/
|
|
190
|
+
pass.setVertexBuffer(0, sprites.instances, first * STRIDE, count * STRIDE);
|
|
191
|
+
pass.draw(6, count);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
export function disposeGpuSprites(sprites) {
|
|
195
|
+
sprites.vertexUniforms.destroy();
|
|
196
|
+
sprites.fragmentUniforms.destroy();
|
|
197
|
+
sprites.instances.destroy();
|
|
198
|
+
for (const slot of sprites.slots)
|
|
199
|
+
if (slot !== null)
|
|
200
|
+
slot.texture.destroy();
|
|
201
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/** The 2D layer as a pass a consumer registers: one batch of quads, drawn where the caller says. */
|
|
2
|
+
import type { PassDefinition } from '@driftengine/core';
|
|
3
|
+
import type { Affine2D } from './camera2d.ts';
|
|
4
|
+
import type { SpriteBatch } from './spriteBatch.ts';
|
|
5
|
+
import type { SpriteImage, SpriteTextureOptions } from './spriteTexture.ts';
|
|
6
|
+
/** Sprites a frame, if the caller does not say. Four thousand quads is 224 KB of instance data. */
|
|
7
|
+
export declare const DEFAULT_SPRITE_CAPACITY = 4096;
|
|
8
|
+
/**
|
|
9
|
+
* Texture slots, if the caller does not say.
|
|
10
|
+
*
|
|
11
|
+
* Eight is a sheet for the world, one for the interface, and room to spare. It is not a GPU limit
|
|
12
|
+
* — each slot is its own bind group on WebGPU and its own object on WebGL2 — it is the number
|
|
13
|
+
* above which a caller should be asking why its 2D layer has that many atlases.
|
|
14
|
+
*/
|
|
15
|
+
export declare const DEFAULT_SPRITE_SLOTS = 8;
|
|
16
|
+
export interface SpritePassOptions {
|
|
17
|
+
readonly capacity?: number;
|
|
18
|
+
readonly slots?: number;
|
|
19
|
+
readonly label?: string;
|
|
20
|
+
}
|
|
21
|
+
export interface SpritePass extends PassDefinition {
|
|
22
|
+
/**
|
|
23
|
+
* The quads for this frame. Reset it, fill it with `drawSprite`, then call `drawPass`.
|
|
24
|
+
*
|
|
25
|
+
* Held by the pass rather than handed in per frame, because its size is the pass's GPU buffer
|
|
26
|
+
* and the two must agree.
|
|
27
|
+
*/
|
|
28
|
+
readonly batch: SpriteBatch;
|
|
29
|
+
/**
|
|
30
|
+
* A slot holding one opaque white texel, filled by the pass and not settable.
|
|
31
|
+
*
|
|
32
|
+
* White is the identity of the multiply the shader does, so a quad on this slot draws exactly its
|
|
33
|
+
* tint — which is what a solid rectangle is, and what `fillPanel` in core does with a whole
|
|
34
|
+
* program of its own. A caller drawing a filled box needs no sheet for it.
|
|
35
|
+
*
|
|
36
|
+
* **A background on this slot and an image from a sheet are two runs**, because a slot change is
|
|
37
|
+
* a run and the order may not be regrouped. A caller with many filled boxes and many images
|
|
38
|
+
* interleaved should pack a white texel into its *own* sheet and tint a frame of that instead:
|
|
39
|
+
* then the whole tree is one run. This slot is the convenience, and the sheet is the fast path.
|
|
40
|
+
*/
|
|
41
|
+
readonly white: number;
|
|
42
|
+
/** Clear the batch. Call once at the top of a frame. */
|
|
43
|
+
reset(): void;
|
|
44
|
+
/**
|
|
45
|
+
* Where the batch's coordinates are, from `screenToNdc` or `worldToNdc`.
|
|
46
|
+
*
|
|
47
|
+
* Copied, so the caller may reuse its array; and read at draw time, so this can be called before
|
|
48
|
+
* the pass has a device.
|
|
49
|
+
*/
|
|
50
|
+
setTransform(affine: Affine2D): void;
|
|
51
|
+
/** Fill a texture slot. Safe before the pass is registered; applied when it gets a device. */
|
|
52
|
+
setTexture(slot: number, source: SpriteImage, options?: SpriteTextureOptions): void;
|
|
53
|
+
}
|
|
54
|
+
export declare function createSpritePass(options?: SpritePassOptions): SpritePass;
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/** The 2D layer as a pass a consumer registers: one batch of quads, drawn where the caller says. */
|
|
2
|
+
import { SPRITE_BINDINGS } from './shaders/generated/sprite.wgsl.js';
|
|
3
|
+
import { createAffine2D } from './camera2d.js';
|
|
4
|
+
import { createSpriteBatch, resetSpriteBatch } from './spriteBatch.js';
|
|
5
|
+
import { createWebgl2Sprites, disposeWebgl2Sprites, drawWebgl2Sprites, setWebgl2SpriteTexture, setWebgl2WhiteTexture, uploadWebgl2Instances, } from './spriteGl.js';
|
|
6
|
+
import { createGpuSprites, disposeGpuSprites, drawGpuSprites, setGpuSpriteTexture, setGpuWhiteTexture, uploadGpuInstances, } from './spriteGpu.js';
|
|
7
|
+
import { DEFAULT_SPRITE_TEXTURE_OPTIONS } from './spriteTexture.js';
|
|
8
|
+
const VERT = SPRITE_BINDINGS.SPRITE_VERT;
|
|
9
|
+
const FRAG = SPRITE_BINDINGS.SPRITE_FRAG;
|
|
10
|
+
/** Identity, so a pass that is drawn before it is transformed puts its sprites in clip space. */
|
|
11
|
+
const IDENTITY_CLIP = new Float32Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]);
|
|
12
|
+
/** Sprites a frame, if the caller does not say. Four thousand quads is 224 KB of instance data. */
|
|
13
|
+
export const DEFAULT_SPRITE_CAPACITY = 4096;
|
|
14
|
+
/**
|
|
15
|
+
* Texture slots, if the caller does not say.
|
|
16
|
+
*
|
|
17
|
+
* Eight is a sheet for the world, one for the interface, and room to spare. It is not a GPU limit
|
|
18
|
+
* — each slot is its own bind group on WebGPU and its own object on WebGL2 — it is the number
|
|
19
|
+
* above which a caller should be asking why its 2D layer has that many atlases.
|
|
20
|
+
*/
|
|
21
|
+
export const DEFAULT_SPRITE_SLOTS = 8;
|
|
22
|
+
export function createSpritePass(options = {}) {
|
|
23
|
+
const capacity = options.capacity ?? DEFAULT_SPRITE_CAPACITY;
|
|
24
|
+
const slots = options.slots ?? DEFAULT_SPRITE_SLOTS;
|
|
25
|
+
const label = options.label ?? 'ui2d.sprites';
|
|
26
|
+
/* One past the caller's slots, so `setTexture` cannot reach it and it cannot be lost. */
|
|
27
|
+
const white = slots;
|
|
28
|
+
const batch = createSpriteBatch(capacity);
|
|
29
|
+
const toNdc = createAffine2D();
|
|
30
|
+
/* Identity as an affine: ndc = (x, y). A caller that never sets one draws in clip space. */
|
|
31
|
+
toNdc[0] = 1;
|
|
32
|
+
toNdc[3] = 1;
|
|
33
|
+
let gl = null;
|
|
34
|
+
let context = null;
|
|
35
|
+
let gpu = null;
|
|
36
|
+
let device = null;
|
|
37
|
+
let clipCorrection = IDENTITY_CLIP;
|
|
38
|
+
const pending = [];
|
|
39
|
+
const applyTexture = (entry) => {
|
|
40
|
+
if (gl !== null && context !== null) {
|
|
41
|
+
setWebgl2SpriteTexture(context, gl, entry.slot, entry.source, entry.options);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
if (gpu !== null && device !== null) {
|
|
45
|
+
setGpuSpriteTexture(device, gpu, entry.slot, entry.source, entry.options);
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
return {
|
|
49
|
+
label,
|
|
50
|
+
batch,
|
|
51
|
+
white,
|
|
52
|
+
reset() {
|
|
53
|
+
resetSpriteBatch(batch);
|
|
54
|
+
},
|
|
55
|
+
setTransform(affine) {
|
|
56
|
+
toNdc.set(affine);
|
|
57
|
+
},
|
|
58
|
+
setTexture(slot, source, textureOptions) {
|
|
59
|
+
if (slot < 0 || slot >= slots) {
|
|
60
|
+
throw new RangeError(`${label}: slot ${slot} is outside the ${slots} this pass has`);
|
|
61
|
+
}
|
|
62
|
+
const entry = {
|
|
63
|
+
slot,
|
|
64
|
+
source,
|
|
65
|
+
options: textureOptions ?? DEFAULT_SPRITE_TEXTURE_OPTIONS,
|
|
66
|
+
};
|
|
67
|
+
if (gl === null && gpu === null) {
|
|
68
|
+
pending.push(entry);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
applyTexture(entry);
|
|
72
|
+
},
|
|
73
|
+
init(passDevice) {
|
|
74
|
+
/*
|
|
75
|
+
* The correction the *matrix* carries, because this stage builds its own clip position and
|
|
76
|
+
* never multiplies by a camera. Without it the generated WGSL's Y negation stands
|
|
77
|
+
* uncancelled and the whole 2D layer lands mirrored — `panel.ts` in core made the same
|
|
78
|
+
* mistake once and its comment is where the reason is written out.
|
|
79
|
+
*/
|
|
80
|
+
clipCorrection = passDevice.clipCorrection;
|
|
81
|
+
if (passDevice.backend === 'webgl2') {
|
|
82
|
+
context = passDevice.gl;
|
|
83
|
+
gl = createWebgl2Sprites(passDevice.gl, capacity, slots + 1, label);
|
|
84
|
+
}
|
|
85
|
+
else {
|
|
86
|
+
device = passDevice.device;
|
|
87
|
+
gpu = createGpuSprites(passDevice.device, passDevice.format, passDevice.depthFormat, passDevice.samples, capacity, slots + 1, label);
|
|
88
|
+
}
|
|
89
|
+
if (gl !== null && context !== null)
|
|
90
|
+
setWebgl2WhiteTexture(context, gl, white);
|
|
91
|
+
if (gpu !== null && device !== null)
|
|
92
|
+
setGpuWhiteTexture(device, gpu, white);
|
|
93
|
+
for (const entry of pending)
|
|
94
|
+
applyTexture(entry);
|
|
95
|
+
pending.length = 0;
|
|
96
|
+
},
|
|
97
|
+
draw(ctx) {
|
|
98
|
+
if (batch.count === 0)
|
|
99
|
+
return;
|
|
100
|
+
if (ctx.backend === 'webgl2') {
|
|
101
|
+
if (gl === null)
|
|
102
|
+
return;
|
|
103
|
+
uploadWebgl2Instances(ctx.gl, gl, batch);
|
|
104
|
+
drawWebgl2Sprites(ctx.gl, gl, batch, toNdc, clipCorrection, ctx.outputTransform, ctx.outputExposure);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
if (gpu === null || device === null)
|
|
108
|
+
return;
|
|
109
|
+
const f = gpu.vertexFloats;
|
|
110
|
+
f[VERT.fields.uToNdc0.offset / 4] = toNdc[0];
|
|
111
|
+
f[VERT.fields.uToNdc0.offset / 4 + 1] = toNdc[1];
|
|
112
|
+
f[VERT.fields.uToNdc0.offset / 4 + 2] = toNdc[2];
|
|
113
|
+
f[VERT.fields.uToNdc0.offset / 4 + 3] = toNdc[3];
|
|
114
|
+
f[VERT.fields.uToNdc1.offset / 4] = toNdc[4];
|
|
115
|
+
f[VERT.fields.uToNdc1.offset / 4 + 1] = toNdc[5];
|
|
116
|
+
f.set(clipCorrection, VERT.fields.uClipCorrection.offset / 4);
|
|
117
|
+
gpu.fragmentInts[FRAG.fields.uOutputTransform.offset / 4] = ctx.outputTransform;
|
|
118
|
+
gpu.fragmentFloats[FRAG.fields.uOutputExposure.offset / 4] = ctx.outputExposure;
|
|
119
|
+
/*
|
|
120
|
+
* Written from inside an open render pass, which is allowed and ordered: a queue write
|
|
121
|
+
* issued now lands before the command buffer this pass is being recorded into is submitted.
|
|
122
|
+
* What it does mean is **one `drawPass` a frame per pass** — a second would overwrite the
|
|
123
|
+
* first's instances before either had executed. A caller that wants the 2D layer in two
|
|
124
|
+
* places in the frame registers two passes, which is also how two splat captures work.
|
|
125
|
+
*/
|
|
126
|
+
device.queue.writeBuffer(gpu.vertexUniforms, 0, gpu.vertexScratch);
|
|
127
|
+
device.queue.writeBuffer(gpu.fragmentUniforms, 0, gpu.fragmentScratch);
|
|
128
|
+
uploadGpuInstances(device, gpu, batch);
|
|
129
|
+
drawGpuSprites(ctx.pass, gpu, batch);
|
|
130
|
+
},
|
|
131
|
+
dispose() {
|
|
132
|
+
if (gl !== null && context !== null)
|
|
133
|
+
disposeWebgl2Sprites(context, gl);
|
|
134
|
+
if (gpu !== null)
|
|
135
|
+
disposeGpuSprites(gpu);
|
|
136
|
+
gl = null;
|
|
137
|
+
gpu = null;
|
|
138
|
+
context = null;
|
|
139
|
+
device = null;
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/** A sheet: the rectangles of one texture that each hold a picture, addressed by index or name. */
|
|
2
|
+
import type { UvRect } from './spriteBatch.ts';
|
|
3
|
+
/** A `UvRect` a caller owns and refills, so reading a frame allocates nothing. */
|
|
4
|
+
export interface SpriteFrame {
|
|
5
|
+
u0: number;
|
|
6
|
+
v0: number;
|
|
7
|
+
u1: number;
|
|
8
|
+
v1: number;
|
|
9
|
+
}
|
|
10
|
+
export declare function createSpriteFrame(): SpriteFrame;
|
|
11
|
+
/** One entry of an atlas, in texels, as a packer emits it. */
|
|
12
|
+
export interface SheetEntry {
|
|
13
|
+
readonly name: string;
|
|
14
|
+
readonly x: number;
|
|
15
|
+
readonly y: number;
|
|
16
|
+
readonly w: number;
|
|
17
|
+
readonly h: number;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* The frames of one texture.
|
|
21
|
+
*
|
|
22
|
+
* Two flat arrays and a name index rather than an array of objects: reading a frame in a draw loop
|
|
23
|
+
* is four `Float32Array` reads and no property lookups, and a tilemap does that per tile.
|
|
24
|
+
*/
|
|
25
|
+
export interface SpriteSheet {
|
|
26
|
+
/** The slot on the pass this sheet's texture was set into. */
|
|
27
|
+
readonly texture: number;
|
|
28
|
+
/** The texture's own size, in texels. */
|
|
29
|
+
readonly width: number;
|
|
30
|
+
readonly height: number;
|
|
31
|
+
readonly count: number;
|
|
32
|
+
/** Four per frame: u0, v0, u1, v1. */
|
|
33
|
+
readonly uvs: Float32Array;
|
|
34
|
+
/** Two per frame: width and height in texels, for drawing a frame at its own scale. */
|
|
35
|
+
readonly sizes: Float32Array;
|
|
36
|
+
readonly names: ReadonlyMap<string, number>;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* A sheet cut into equal cells, row-major: left to right, then down.
|
|
40
|
+
*
|
|
41
|
+
* **A cell that would run off the edge is not emitted.** A sheet 70 texels wide cut into sixteens
|
|
42
|
+
* has four whole cells and six texels of margin, and a fifth cell reading into that margin is a
|
|
43
|
+
* frame with a stripe of nothing down one side — which reads as a rendering bug rather than as a
|
|
44
|
+
* badly measured atlas.
|
|
45
|
+
*
|
|
46
|
+
* Frames are named `"0"`, `"1"` and so on, so `frameOf` works on a grid too.
|
|
47
|
+
*/
|
|
48
|
+
export declare function gridSheet(texture: number, width: number, height: number, cellWidth: number, cellHeight: number): SpriteSheet;
|
|
49
|
+
/** A sheet from an atlas description: whatever rectangles a packer put where. */
|
|
50
|
+
export declare function namedSheet(texture: number, width: number, height: number, entries: readonly SheetEntry[]): SpriteSheet;
|
|
51
|
+
/** The index a name has, or `-1`. */
|
|
52
|
+
export declare function frameOf(sheet: SpriteSheet, name: string): number;
|
|
53
|
+
/**
|
|
54
|
+
* Read a frame into a rectangle the caller owns. Allocates nothing.
|
|
55
|
+
*
|
|
56
|
+
* **An index this sheet does not have reads as the whole texture rather than throwing**, because
|
|
57
|
+
* this is called per sprite per frame and the rule is that nothing throws in the frame loop. What
|
|
58
|
+
* a caller then sees is the entire atlas drawn where one picture should be, which is unmistakable.
|
|
59
|
+
*/
|
|
60
|
+
export declare function sheetFrame(sheet: SpriteSheet, index: number, out: SpriteFrame): UvRect;
|
|
61
|
+
/** A frame's width in texels, or 0 for an index the sheet does not have. */
|
|
62
|
+
export declare function sheetFrameWidth(sheet: SpriteSheet, index: number): number;
|
|
63
|
+
/** A frame's height in texels, or 0 for an index the sheet does not have. */
|
|
64
|
+
export declare function sheetFrameHeight(sheet: SpriteSheet, index: number): number;
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/** A sheet: the rectangles of one texture that each hold a picture, addressed by index or name. */
|
|
2
|
+
export function createSpriteFrame() {
|
|
3
|
+
return { u0: 0, v0: 0, u1: 1, v1: 1 };
|
|
4
|
+
}
|
|
5
|
+
function build(texture, width, height, entries) {
|
|
6
|
+
const uvs = new Float32Array(entries.length * 4);
|
|
7
|
+
const sizes = new Float32Array(entries.length * 2);
|
|
8
|
+
const names = new Map();
|
|
9
|
+
entries.forEach((entry, index) => {
|
|
10
|
+
uvs[index * 4] = entry.x / width;
|
|
11
|
+
uvs[index * 4 + 1] = entry.y / height;
|
|
12
|
+
uvs[index * 4 + 2] = (entry.x + entry.w) / width;
|
|
13
|
+
uvs[index * 4 + 3] = (entry.y + entry.h) / height;
|
|
14
|
+
sizes[index * 2] = entry.w;
|
|
15
|
+
sizes[index * 2 + 1] = entry.h;
|
|
16
|
+
names.set(entry.name, index);
|
|
17
|
+
});
|
|
18
|
+
return { texture, width, height, count: entries.length, uvs, sizes, names };
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* A sheet cut into equal cells, row-major: left to right, then down.
|
|
22
|
+
*
|
|
23
|
+
* **A cell that would run off the edge is not emitted.** A sheet 70 texels wide cut into sixteens
|
|
24
|
+
* has four whole cells and six texels of margin, and a fifth cell reading into that margin is a
|
|
25
|
+
* frame with a stripe of nothing down one side — which reads as a rendering bug rather than as a
|
|
26
|
+
* badly measured atlas.
|
|
27
|
+
*
|
|
28
|
+
* Frames are named `"0"`, `"1"` and so on, so `frameOf` works on a grid too.
|
|
29
|
+
*/
|
|
30
|
+
export function gridSheet(texture, width, height, cellWidth, cellHeight) {
|
|
31
|
+
const columns = Math.floor(width / cellWidth);
|
|
32
|
+
const rows = Math.floor(height / cellHeight);
|
|
33
|
+
const entries = [];
|
|
34
|
+
for (let row = 0; row < rows; row += 1) {
|
|
35
|
+
for (let column = 0; column < columns; column += 1) {
|
|
36
|
+
entries.push({
|
|
37
|
+
name: String(entries.length),
|
|
38
|
+
x: column * cellWidth,
|
|
39
|
+
y: row * cellHeight,
|
|
40
|
+
w: cellWidth,
|
|
41
|
+
h: cellHeight,
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return build(texture, width, height, entries);
|
|
46
|
+
}
|
|
47
|
+
/** A sheet from an atlas description: whatever rectangles a packer put where. */
|
|
48
|
+
export function namedSheet(texture, width, height, entries) {
|
|
49
|
+
return build(texture, width, height, entries);
|
|
50
|
+
}
|
|
51
|
+
/** The index a name has, or `-1`. */
|
|
52
|
+
export function frameOf(sheet, name) {
|
|
53
|
+
return sheet.names.get(name) ?? -1;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Read a frame into a rectangle the caller owns. Allocates nothing.
|
|
57
|
+
*
|
|
58
|
+
* **An index this sheet does not have reads as the whole texture rather than throwing**, because
|
|
59
|
+
* this is called per sprite per frame and the rule is that nothing throws in the frame loop. What
|
|
60
|
+
* a caller then sees is the entire atlas drawn where one picture should be, which is unmistakable.
|
|
61
|
+
*/
|
|
62
|
+
export function sheetFrame(sheet, index, out) {
|
|
63
|
+
if (index < 0 || index >= sheet.count) {
|
|
64
|
+
out.u0 = 0;
|
|
65
|
+
out.v0 = 0;
|
|
66
|
+
out.u1 = 1;
|
|
67
|
+
out.v1 = 1;
|
|
68
|
+
return out;
|
|
69
|
+
}
|
|
70
|
+
const at = index * 4;
|
|
71
|
+
out.u0 = sheet.uvs[at];
|
|
72
|
+
out.v0 = sheet.uvs[at + 1];
|
|
73
|
+
out.u1 = sheet.uvs[at + 2];
|
|
74
|
+
out.v1 = sheet.uvs[at + 3];
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
77
|
+
/** A frame's width in texels, or 0 for an index the sheet does not have. */
|
|
78
|
+
export function sheetFrameWidth(sheet, index) {
|
|
79
|
+
return index < 0 || index >= sheet.count ? 0 : sheet.sizes[index * 2];
|
|
80
|
+
}
|
|
81
|
+
/** A frame's height in texels, or 0 for an index the sheet does not have. */
|
|
82
|
+
export function sheetFrameHeight(sheet, index) {
|
|
83
|
+
return index < 0 || index >= sheet.count ? 0 : sheet.sizes[index * 2 + 1];
|
|
84
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/** What a caller hands over as a sprite texture, and what it says about it. */
|
|
2
|
+
/**
|
|
3
|
+
* The three sources both backends take without a conversion.
|
|
4
|
+
*
|
|
5
|
+
* Narrower than `TexImageSource` on purpose. WebGPU's `copyExternalImageToTexture` refuses an
|
|
6
|
+
* `ImageData` outright, and an `HTMLImageElement` is accepted by only some implementations — so
|
|
7
|
+
* the union that is honestly portable is a bitmap and the two canvases. A caller with an `<img>`
|
|
8
|
+
* reaches this through one `createImageBitmap`, which is also where the decode's own orientation
|
|
9
|
+
* and premultiply options live, and those are decisions worth making in the open.
|
|
10
|
+
*
|
|
11
|
+
* All three carry their own `width` and `height`, which is the other reason: nothing here has to
|
|
12
|
+
* ask the caller how big the image it just handed over is.
|
|
13
|
+
*/
|
|
14
|
+
export type SpriteImage = ImageBitmap | HTMLCanvasElement | OffscreenCanvas;
|
|
15
|
+
export interface SpriteTextureOptions {
|
|
16
|
+
/**
|
|
17
|
+
* `srgb` is right for anything painted to be looked at, which is every sprite sheet — the
|
|
18
|
+
* sampler decodes and the pass's own output transform re-encodes, so the blend happens in
|
|
19
|
+
* linear light where it belongs. `linear` is for a sheet carrying data rather than colour: a
|
|
20
|
+
* mask, a height, a flow field.
|
|
21
|
+
*
|
|
22
|
+
* The same name and the same two values `SurfaceTexture` already takes, so a consumer that has
|
|
23
|
+
* uploaded a texture to the mesh path does not meet a second vocabulary here.
|
|
24
|
+
*/
|
|
25
|
+
readonly colorSpace?: 'linear' | 'srgb';
|
|
26
|
+
/**
|
|
27
|
+
* `nearest` by default, and the default is the argument.
|
|
28
|
+
*
|
|
29
|
+
* A sprite sheet is usually pixel art, where linear filtering is the thing that makes it look
|
|
30
|
+
* wrong; and a sheet that is *not* pixel art is normally drawn near its authored size, where
|
|
31
|
+
* the two filters differ by very little. So the default costs the smooth case almost nothing
|
|
32
|
+
* and saves the sharp case from a blur nobody asked for.
|
|
33
|
+
*/
|
|
34
|
+
readonly filter?: 'nearest' | 'linear';
|
|
35
|
+
}
|
|
36
|
+
export declare const DEFAULT_SPRITE_TEXTURE_OPTIONS: SpriteTextureOptions;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/** A grid of sheet frames, drawn as sprites, culled to what the view can see. */
|
|
2
|
+
import type { SpriteBatch } from './spriteBatch.ts';
|
|
3
|
+
import type { SpriteSheet } from './spriteSheet.ts';
|
|
4
|
+
/** A cell holding nothing. Negative, so it can never be a frame index. */
|
|
5
|
+
export declare const TILE_EMPTY = -1;
|
|
6
|
+
export interface Tilemap {
|
|
7
|
+
readonly columns: number;
|
|
8
|
+
readonly rows: number;
|
|
9
|
+
/** One frame index per cell, row-major. `TILE_EMPTY` for a cell with nothing in it. */
|
|
10
|
+
readonly tiles: Int32Array;
|
|
11
|
+
/** How big a cell is, in whatever units the batch's affine maps from. */
|
|
12
|
+
readonly tileWidth: number;
|
|
13
|
+
readonly tileHeight: number;
|
|
14
|
+
/**
|
|
15
|
+
* Where cell (0, 0)'s corner sits.
|
|
16
|
+
*
|
|
17
|
+
* Mutable, because scrolling a map is moving it and the alternative is rebuilding one.
|
|
18
|
+
*/
|
|
19
|
+
x: number;
|
|
20
|
+
y: number;
|
|
21
|
+
}
|
|
22
|
+
/** What the view can see, in the same units the map is placed in. */
|
|
23
|
+
export interface ViewRect {
|
|
24
|
+
readonly x: number;
|
|
25
|
+
readonly y: number;
|
|
26
|
+
readonly w: number;
|
|
27
|
+
readonly h: number;
|
|
28
|
+
}
|
|
29
|
+
export declare function createTilemap(columns: number, rows: number, tileWidth: number, tileHeight: number): Tilemap;
|
|
30
|
+
/** The frame in a cell, or `TILE_EMPTY` — including for a cell outside the map. */
|
|
31
|
+
export declare function tileAt(map: Tilemap, column: number, row: number): number;
|
|
32
|
+
/** Put a frame in a cell. A cell outside the map is ignored rather than wrapping into another row. */
|
|
33
|
+
export declare function setTile(map: Tilemap, column: number, row: number, tile: number): void;
|
|
34
|
+
/**
|
|
35
|
+
* Draw the tiles the view can see. Returns how many that was.
|
|
36
|
+
*
|
|
37
|
+
* **The cost is the view rather than the map**, which is the whole reason this is a function and
|
|
38
|
+
* not a loop the caller writes: the visible span is arithmetic on four numbers, so a map of a
|
|
39
|
+
* million cells costs the few hundred on screen. A walk over every cell testing each against the
|
|
40
|
+
* view would draw exactly the same picture and be unusable at the size a tilemap exists for.
|
|
41
|
+
*
|
|
42
|
+
* **Which way the rows run is the affine's business, not this function's.** A cell's corner is
|
|
43
|
+
* `y + row * tileHeight`, so in screen space — where y counts down — row 0 is the top row, and in a
|
|
44
|
+
* 2D world — where y counts up — it is the bottom. That is the same rule `SpritePlacement` states
|
|
45
|
+
* about its own corner, and having one rule rather than a flag is what keeps the two agreeing.
|
|
46
|
+
*/
|
|
47
|
+
export declare function drawTilemap(batch: SpriteBatch, map: Tilemap, sheet: SpriteSheet, view: ViewRect, tint: ArrayLike<number> | null): number;
|