@driftengine/ui2d 3.62.0 → 3.63.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/README.md +27 -0
- package/dist/spriteGl.js +19 -3
- package/dist/spriteGpu.d.ts +5 -0
- package/dist/spriteGpu.js +63 -2
- package/dist/spriteTexture.d.ts +30 -0
- package/dist/spriteTexture.js +1 -0
- package/package.json +2 -2
- package/src/spriteGl.ts +22 -3
- package/src/spriteGpu.ts +73 -3
- package/src/spriteTexture.ts +31 -0
package/README.md
CHANGED
|
@@ -119,6 +119,33 @@ decodes and the pass's own output transform re-encodes, so the blend happens in
|
|
|
119
119
|
`filter` defaults to `nearest`, because a sheet is usually pixel art and linear filtering is the
|
|
120
120
|
thing that makes pixel art look wrong.
|
|
121
121
|
|
|
122
|
+
### Type in an atlas wants `mipmap: true`
|
|
123
|
+
|
|
124
|
+
`mipmap` defaults to false, which is right for pixel art: a chain is memory nothing samples, and at
|
|
125
|
+
a distance it dissolves art whose whole point is the pixel. **A glyph page is the sheet where that
|
|
126
|
+
default is wrong.** Bake one page at 96 px, draw body copy at 11, and that is a 7x minification:
|
|
127
|
+
`linear` reads four texels out of a footprint covering dozens, so a `t` crossbar two texels tall
|
|
128
|
+
lands on about a quarter of a pixel and survives or not depending on where the sample falls. A
|
|
129
|
+
player reported it as `Step-In Uppercut` reading `Slep-In Uppercul`, with the same letter surviving
|
|
130
|
+
in one word and not the next, so the line looked unevenly spaced as well as misread.
|
|
131
|
+
|
|
132
|
+
```ts
|
|
133
|
+
sprites.setTexture(0, glyphPage, { filter: 'linear', mipmap: true });
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
Measured on `demo/dev/glyphMip.ts`, twelve copies of one glyph at different subpixel offsets: the
|
|
137
|
+
ink varies between copies with a standard deviation of **0.0130** plain and **0.0020** mipmapped, so
|
|
138
|
+
**6.6x less** copy-to-copy disagreement, identical on both backends. `scripts/glyph-mip-check.mjs`
|
|
139
|
+
is that measurement as a gate.
|
|
140
|
+
|
|
141
|
+
With `filter: 'linear'` this is trilinear. With `filter: 'nearest'` the levels are still blended,
|
|
142
|
+
because that is minification and `filter` is about magnification.
|
|
143
|
+
|
|
144
|
+
**Pad the cells yourself.** A lower level mixes texels the packer put next to each other, so a sheet
|
|
145
|
+
whose frames touch shows its neighbours once the chain is deep enough to reach them. How much
|
|
146
|
+
padding depends on how far down the sheet is ever sampled, which is a fact about the sheet rather
|
|
147
|
+
than about the sampler — the engine cannot size it for you.
|
|
148
|
+
|
|
122
149
|
**Neither backend flips the image.** `surfaceTexture.ts` in core carries the whole argument and the
|
|
123
150
|
bug it came from: WebGL2 ignores `UNPACK_FLIP_Y_WEBGL` for an `ImageBitmap` and honours it for a
|
|
124
151
|
canvas, so a pipeline checked with one source type is half checked. `scripts/sprite-check.mjs`
|
package/dist/spriteGl.js
CHANGED
|
@@ -97,9 +97,21 @@ export function setWebgl2SpriteTexture(gl, sprites, slot, source, options) {
|
|
|
97
97
|
gl.bindTexture(gl.TEXTURE_2D, texture);
|
|
98
98
|
const internal = options.colorSpace === 'linear' ? gl.RGBA : gl.SRGB8_ALPHA8;
|
|
99
99
|
gl.texImage2D(gl.TEXTURE_2D, 0, internal, gl.RGBA, gl.UNSIGNED_BYTE, source);
|
|
100
|
-
const
|
|
101
|
-
gl.texParameteri(gl.TEXTURE_2D, gl.
|
|
102
|
-
|
|
100
|
+
const linear = options.filter === 'linear';
|
|
101
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, linear ? gl.LINEAR : gl.NEAREST);
|
|
102
|
+
/*
|
|
103
|
+
* **Minification is the chain's question and magnification is `filter`'s**, which is why the two
|
|
104
|
+
* are set apart here. A mipmapped sheet blends *between* levels whichever filter was asked for:
|
|
105
|
+
* that is what stops a stroke disappearing on the way down, and `filter` still decides whether
|
|
106
|
+
* the texels inside a level are blended. `SurfaceTexture` splits it the same way.
|
|
107
|
+
*/
|
|
108
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, options.mipmap === true
|
|
109
|
+
? linear
|
|
110
|
+
? gl.LINEAR_MIPMAP_LINEAR
|
|
111
|
+
: gl.NEAREST_MIPMAP_LINEAR
|
|
112
|
+
: linear
|
|
113
|
+
? gl.LINEAR
|
|
114
|
+
: gl.NEAREST);
|
|
103
115
|
/*
|
|
104
116
|
* Clamped on both axes, and it is a correctness rule rather than a default. A sheet frame's
|
|
105
117
|
* edge texel is adjacent to the *next* frame's, so a repeating wrap bleeds one sprite into
|
|
@@ -107,6 +119,10 @@ export function setWebgl2SpriteTexture(gl, sprites, slot, source, options) {
|
|
|
107
119
|
*/
|
|
108
120
|
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
|
109
121
|
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
|
122
|
+
/* After the upload, which is the only order that works: the chain is built from level 0, and a
|
|
123
|
+
driver that has not been given one yet builds nothing from it. */
|
|
124
|
+
if (options.mipmap === true)
|
|
125
|
+
gl.generateMipmap(gl.TEXTURE_2D);
|
|
110
126
|
gl.bindTexture(gl.TEXTURE_2D, null);
|
|
111
127
|
gl.activeTexture(gl.TEXTURE0);
|
|
112
128
|
sprites.textures[slot] = texture;
|
package/dist/spriteGpu.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/** The WebGPU half of the sprite pass: one pipeline, one instance buffer, a bind group per slot. */
|
|
2
2
|
import type { SpriteBatch } from './spriteBatch.ts';
|
|
3
|
+
import { type MipPipelines } from '@driftengine/core';
|
|
3
4
|
import type { SpriteImage, SpriteTextureOptions } from './spriteTexture.ts';
|
|
4
5
|
export interface GpuSpriteSlot {
|
|
5
6
|
readonly texture: GPUTexture;
|
|
@@ -14,7 +15,11 @@ export interface GpuSprites {
|
|
|
14
15
|
readonly samplers: {
|
|
15
16
|
readonly nearest: GPUSampler;
|
|
16
17
|
readonly linear: GPUSampler;
|
|
18
|
+
readonly nearestMip: GPUSampler;
|
|
19
|
+
readonly linearMip: GPUSampler;
|
|
17
20
|
};
|
|
21
|
+
/** One mip-blit pipeline per format, built on first use. See `generateMipChain`. */
|
|
22
|
+
readonly mipPipelines: MipPipelines;
|
|
18
23
|
readonly slots: (GpuSpriteSlot | null)[];
|
|
19
24
|
readonly vertexScratch: ArrayBuffer;
|
|
20
25
|
readonly vertexFloats: Float32Array;
|
package/dist/spriteGpu.js
CHANGED
|
@@ -1,6 +1,16 @@
|
|
|
1
1
|
/** The WebGPU half of the sprite pass: one pipeline, one instance buffer, a bind group per slot. */
|
|
2
2
|
import { SPRITE_BINDINGS, SPRITE_FRAG_WGSL, SPRITE_VERT_WGSL, } from './shaders/generated/sprite.wgsl.js';
|
|
3
3
|
import { SPRITE_FLOATS } from './spriteBatch.js';
|
|
4
|
+
/*
|
|
5
|
+
* **The first runtime import this package takes from the engine, and it was measured before it was
|
|
6
|
+
* taken.** Everything else here is `import type`, which erases, so `ui2d` has had no runtime edge
|
|
7
|
+
* to `core` at all — and a value import from a barrel is how a package quietly acquires the whole
|
|
8
|
+
* of another one. Bundled alone, with the import and with it stubbed out: **32,801 against 31,031
|
|
9
|
+
* raw and 11,777 against 11,163 gzipped**, so 614 gzipped bytes, which is the blit and its shader
|
|
10
|
+
* and nothing else. The alternative was a second copy of the mip blit living here, and the second
|
|
11
|
+
* copy is the one that ends up disagreeing about the colour space.
|
|
12
|
+
*/
|
|
13
|
+
import { generateMipChain, mipLevelCount } from '@driftengine/core';
|
|
4
14
|
const VERT = SPRITE_BINDINGS.SPRITE_VERT;
|
|
5
15
|
const FRAG = SPRITE_BINDINGS.SPRITE_FRAG;
|
|
6
16
|
const STRIDE = SPRITE_FLOATS * 4;
|
|
@@ -104,7 +114,23 @@ export function createGpuSprites(device, format, depthFormat, samples, capacity,
|
|
|
104
114
|
magFilter: 'linear',
|
|
105
115
|
minFilter: 'linear',
|
|
106
116
|
}),
|
|
117
|
+
/*
|
|
118
|
+
* The mipmapped pair. `mipmapFilter` is the level blend and is `linear` in both, because
|
|
119
|
+
* that is minification; `magFilter` is what `filter` was asked about. Four samplers rather
|
|
120
|
+
* than two because the questions are independent, and the same split `SurfaceTexture` makes.
|
|
121
|
+
*/
|
|
122
|
+
nearestMip: device.createSampler({
|
|
123
|
+
label: `${label}.nearestMip`,
|
|
124
|
+
mipmapFilter: 'linear',
|
|
125
|
+
}),
|
|
126
|
+
linearMip: device.createSampler({
|
|
127
|
+
label: `${label}.linearMip`,
|
|
128
|
+
magFilter: 'linear',
|
|
129
|
+
minFilter: 'linear',
|
|
130
|
+
mipmapFilter: 'linear',
|
|
131
|
+
}),
|
|
107
132
|
},
|
|
133
|
+
mipPipelines: mipPipelineCache(device),
|
|
108
134
|
slots: new Array(slots).fill(null),
|
|
109
135
|
vertexScratch,
|
|
110
136
|
vertexFloats: new Float32Array(vertexScratch),
|
|
@@ -113,14 +139,39 @@ export function createGpuSprites(device, format, depthFormat, samples, capacity,
|
|
|
113
139
|
fragmentInts: new Int32Array(fragmentScratch),
|
|
114
140
|
};
|
|
115
141
|
}
|
|
142
|
+
/**
|
|
143
|
+
* One pipeline per format, kept on the sprite pass rather than in the renderer's cache.
|
|
144
|
+
*
|
|
145
|
+
* The renderer's `PipelineCache` is not reachable from a contributed pass, and the chain builder
|
|
146
|
+
* takes an interface for exactly that reason. A `Map` is the whole of what is needed: there are at
|
|
147
|
+
* most two formats here, and they are built once each.
|
|
148
|
+
*/
|
|
149
|
+
function mipPipelineCache(device) {
|
|
150
|
+
const built = new Map();
|
|
151
|
+
return {
|
|
152
|
+
get(key, describe) {
|
|
153
|
+
const existing = built.get(key);
|
|
154
|
+
if (existing !== undefined)
|
|
155
|
+
return existing;
|
|
156
|
+
const pipeline = device.createRenderPipeline(describe());
|
|
157
|
+
built.set(key, pipeline);
|
|
158
|
+
return pipeline;
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
}
|
|
116
162
|
export function setGpuSpriteTexture(device, sprites, slot, source, options) {
|
|
117
163
|
const previous = sprites.slots[slot];
|
|
118
164
|
if (previous !== null && previous !== undefined)
|
|
119
165
|
previous.texture.destroy();
|
|
166
|
+
const format = options.colorSpace === 'linear' ? 'rgba8unorm' : 'rgba8unorm-srgb';
|
|
167
|
+
/* One level unless a chain was asked for. `TEXTURE_USAGE` already carries RENDER_ATTACHMENT,
|
|
168
|
+
which is what the blit needs to draw into each level. */
|
|
169
|
+
const levels = options.mipmap === true ? mipLevelCount(source.width, source.height) : 1;
|
|
120
170
|
const texture = device.createTexture({
|
|
121
171
|
label: `ui2d.sprite.${slot}`,
|
|
122
172
|
size: [source.width, source.height],
|
|
123
|
-
format
|
|
173
|
+
format,
|
|
174
|
+
mipLevelCount: levels,
|
|
124
175
|
usage: TEXTURE_USAGE,
|
|
125
176
|
});
|
|
126
177
|
/*
|
|
@@ -130,7 +181,17 @@ export function setGpuSpriteTexture(device, sprites, slot, source, options) {
|
|
|
130
181
|
* the source type most callers use.
|
|
131
182
|
*/
|
|
132
183
|
device.queue.copyExternalImageToTexture({ source: source, flipY: false }, { texture }, [source.width, source.height]);
|
|
133
|
-
|
|
184
|
+
/* After the copy, which is the only order that works: every level is rendered from the one above
|
|
185
|
+
it, and level 0 has to hold the image before the chain can be built from it. */
|
|
186
|
+
generateMipChain(device, sprites.mipPipelines, texture, format, levels);
|
|
187
|
+
const linear = options.filter === 'linear';
|
|
188
|
+
const sampler = levels > 1
|
|
189
|
+
? linear
|
|
190
|
+
? sprites.samplers.linearMip
|
|
191
|
+
: sprites.samplers.nearestMip
|
|
192
|
+
: linear
|
|
193
|
+
? sprites.samplers.linear
|
|
194
|
+
: sprites.samplers.nearest;
|
|
134
195
|
const bindGroup = device.createBindGroup({
|
|
135
196
|
label: `ui2d.sprite.${slot}.bindGroup`,
|
|
136
197
|
layout: sprites.layout,
|
package/dist/spriteTexture.d.ts
CHANGED
|
@@ -32,5 +32,35 @@ export interface SpriteTextureOptions {
|
|
|
32
32
|
* and saves the sharp case from a blur nobody asked for.
|
|
33
33
|
*/
|
|
34
34
|
readonly filter?: 'nearest' | 'linear';
|
|
35
|
+
/**
|
|
36
|
+
* Whether to build a mip chain and sample it. `false` by default, which is the sheet above.
|
|
37
|
+
*
|
|
38
|
+
* **The case it exists for is type baked into an atlas.** The comment on `filter` says a sheet
|
|
39
|
+
* that is not pixel art "is normally drawn near its authored size, where the two filters differ
|
|
40
|
+
* by very little" — a glyph page is the sheet where that is not true. A consumer baking one page
|
|
41
|
+
* per weight at 96 px and drawing body copy at 11 is minifying **7x**, and `linear` reads four
|
|
42
|
+
* texels of a footprint that covers dozens: a `t` crossbar two texels tall lands on about a
|
|
43
|
+
* quarter of a pixel, and whether it survives depends on where the sample falls. Reported by a
|
|
44
|
+
* player as `Step-In Uppercut` reading `Slep-In Uppercul`, with different strokes lost per
|
|
45
|
+
* glyph and per position, so the line looks unevenly spaced as well as misread.
|
|
46
|
+
*
|
|
47
|
+
* Neither workaround available to a consumer is good: baking nearer the drawn size blurs the
|
|
48
|
+
* headings, because one atlas cannot serve an 8x range of sizes, and baking a second atlas for
|
|
49
|
+
* small text doubles the pages and the uploads and moves a sampling decision into the game where
|
|
50
|
+
* it has to be re-tuned whenever a size changes.
|
|
51
|
+
*
|
|
52
|
+
* **Off by default because the chain is wrong for pixel art**, which is what most sheets are: it
|
|
53
|
+
* is memory nothing samples, and at a distance it dissolves art whose whole point is the pixel.
|
|
54
|
+
*
|
|
55
|
+
* With `filter: 'linear'` this is trilinear — blended within a level and between levels. With
|
|
56
|
+
* `filter: 'nearest'` the levels are still blended, because that is minification and `filter` is
|
|
57
|
+
* about magnification; `SurfaceTexture` makes the same split and for the same reason.
|
|
58
|
+
*
|
|
59
|
+
* **Padding cells against bleed is the caller's problem**, and it has to be: a lower level mixes
|
|
60
|
+
* texels the atlas packer put next to each other, so a sheet whose frames touch will show its
|
|
61
|
+
* neighbours. How much padding depends on how far the chain is allowed to go, which is a fact
|
|
62
|
+
* about the sheet rather than about the sampler.
|
|
63
|
+
*/
|
|
64
|
+
readonly mipmap?: boolean;
|
|
35
65
|
}
|
|
36
66
|
export declare const DEFAULT_SPRITE_TEXTURE_OPTIONS: SpriteTextureOptions;
|
package/dist/spriteTexture.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@driftengine/ui2d",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.63.0",
|
|
4
4
|
"description": "The 2D layer: batched sprites, sheets, tilemaps, and a retained interface tree over them",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
],
|
|
28
28
|
"sideEffects": false,
|
|
29
29
|
"peerDependencies": {
|
|
30
|
-
"@driftengine/core": "3.
|
|
30
|
+
"@driftengine/core": "3.63.0"
|
|
31
31
|
},
|
|
32
32
|
"author": "Drift Technologies",
|
|
33
33
|
"repository": {
|
package/src/spriteGl.ts
CHANGED
|
@@ -129,9 +129,25 @@ export function setWebgl2SpriteTexture(
|
|
|
129
129
|
gl.bindTexture(gl.TEXTURE_2D, texture);
|
|
130
130
|
const internal = options.colorSpace === 'linear' ? gl.RGBA : gl.SRGB8_ALPHA8;
|
|
131
131
|
gl.texImage2D(gl.TEXTURE_2D, 0, internal, gl.RGBA, gl.UNSIGNED_BYTE, source);
|
|
132
|
-
const
|
|
133
|
-
gl.texParameteri(gl.TEXTURE_2D, gl.
|
|
134
|
-
|
|
132
|
+
const linear = options.filter === 'linear';
|
|
133
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, linear ? gl.LINEAR : gl.NEAREST);
|
|
134
|
+
/*
|
|
135
|
+
* **Minification is the chain's question and magnification is `filter`'s**, which is why the two
|
|
136
|
+
* are set apart here. A mipmapped sheet blends *between* levels whichever filter was asked for:
|
|
137
|
+
* that is what stops a stroke disappearing on the way down, and `filter` still decides whether
|
|
138
|
+
* the texels inside a level are blended. `SurfaceTexture` splits it the same way.
|
|
139
|
+
*/
|
|
140
|
+
gl.texParameteri(
|
|
141
|
+
gl.TEXTURE_2D,
|
|
142
|
+
gl.TEXTURE_MIN_FILTER,
|
|
143
|
+
options.mipmap === true
|
|
144
|
+
? linear
|
|
145
|
+
? gl.LINEAR_MIPMAP_LINEAR
|
|
146
|
+
: gl.NEAREST_MIPMAP_LINEAR
|
|
147
|
+
: linear
|
|
148
|
+
? gl.LINEAR
|
|
149
|
+
: gl.NEAREST,
|
|
150
|
+
);
|
|
135
151
|
/*
|
|
136
152
|
* Clamped on both axes, and it is a correctness rule rather than a default. A sheet frame's
|
|
137
153
|
* edge texel is adjacent to the *next* frame's, so a repeating wrap bleeds one sprite into
|
|
@@ -139,6 +155,9 @@ export function setWebgl2SpriteTexture(
|
|
|
139
155
|
*/
|
|
140
156
|
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
|
141
157
|
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
|
158
|
+
/* After the upload, which is the only order that works: the chain is built from level 0, and a
|
|
159
|
+
driver that has not been given one yet builds nothing from it. */
|
|
160
|
+
if (options.mipmap === true) gl.generateMipmap(gl.TEXTURE_2D);
|
|
142
161
|
gl.bindTexture(gl.TEXTURE_2D, null);
|
|
143
162
|
gl.activeTexture(gl.TEXTURE0);
|
|
144
163
|
sprites.textures[slot] = texture;
|
package/src/spriteGpu.ts
CHANGED
|
@@ -7,6 +7,16 @@ import {
|
|
|
7
7
|
} from './shaders/generated/sprite.wgsl.ts';
|
|
8
8
|
import { SPRITE_FLOATS } from './spriteBatch.ts';
|
|
9
9
|
import type { SpriteBatch } from './spriteBatch.ts';
|
|
10
|
+
/*
|
|
11
|
+
* **The first runtime import this package takes from the engine, and it was measured before it was
|
|
12
|
+
* taken.** Everything else here is `import type`, which erases, so `ui2d` has had no runtime edge
|
|
13
|
+
* to `core` at all — and a value import from a barrel is how a package quietly acquires the whole
|
|
14
|
+
* of another one. Bundled alone, with the import and with it stubbed out: **32,801 against 31,031
|
|
15
|
+
* raw and 11,777 against 11,163 gzipped**, so 614 gzipped bytes, which is the blit and its shader
|
|
16
|
+
* and nothing else. The alternative was a second copy of the mip blit living here, and the second
|
|
17
|
+
* copy is the one that ends up disagreeing about the colour space.
|
|
18
|
+
*/
|
|
19
|
+
import { generateMipChain, mipLevelCount, type MipPipelines } from '@driftengine/core';
|
|
10
20
|
import type { SpriteImage, SpriteTextureOptions } from './spriteTexture.ts';
|
|
11
21
|
|
|
12
22
|
const VERT = SPRITE_BINDINGS.SPRITE_VERT;
|
|
@@ -30,7 +40,14 @@ export interface GpuSprites {
|
|
|
30
40
|
readonly vertexUniforms: GPUBuffer;
|
|
31
41
|
readonly fragmentUniforms: GPUBuffer;
|
|
32
42
|
readonly instances: GPUBuffer;
|
|
33
|
-
readonly samplers: {
|
|
43
|
+
readonly samplers: {
|
|
44
|
+
readonly nearest: GPUSampler;
|
|
45
|
+
readonly linear: GPUSampler;
|
|
46
|
+
readonly nearestMip: GPUSampler;
|
|
47
|
+
readonly linearMip: GPUSampler;
|
|
48
|
+
};
|
|
49
|
+
/** One mip-blit pipeline per format, built on first use. See `generateMipChain`. */
|
|
50
|
+
readonly mipPipelines: MipPipelines;
|
|
34
51
|
readonly slots: (GpuSpriteSlot | null)[];
|
|
35
52
|
readonly vertexScratch: ArrayBuffer;
|
|
36
53
|
readonly vertexFloats: Float32Array;
|
|
@@ -146,7 +163,23 @@ export function createGpuSprites(
|
|
|
146
163
|
magFilter: 'linear',
|
|
147
164
|
minFilter: 'linear',
|
|
148
165
|
}),
|
|
166
|
+
/*
|
|
167
|
+
* The mipmapped pair. `mipmapFilter` is the level blend and is `linear` in both, because
|
|
168
|
+
* that is minification; `magFilter` is what `filter` was asked about. Four samplers rather
|
|
169
|
+
* than two because the questions are independent, and the same split `SurfaceTexture` makes.
|
|
170
|
+
*/
|
|
171
|
+
nearestMip: device.createSampler({
|
|
172
|
+
label: `${label}.nearestMip`,
|
|
173
|
+
mipmapFilter: 'linear',
|
|
174
|
+
}),
|
|
175
|
+
linearMip: device.createSampler({
|
|
176
|
+
label: `${label}.linearMip`,
|
|
177
|
+
magFilter: 'linear',
|
|
178
|
+
minFilter: 'linear',
|
|
179
|
+
mipmapFilter: 'linear',
|
|
180
|
+
}),
|
|
149
181
|
},
|
|
182
|
+
mipPipelines: mipPipelineCache(device),
|
|
150
183
|
slots: new Array<null>(slots).fill(null),
|
|
151
184
|
vertexScratch,
|
|
152
185
|
vertexFloats: new Float32Array(vertexScratch),
|
|
@@ -156,6 +189,26 @@ export function createGpuSprites(
|
|
|
156
189
|
};
|
|
157
190
|
}
|
|
158
191
|
|
|
192
|
+
/**
|
|
193
|
+
* One pipeline per format, kept on the sprite pass rather than in the renderer's cache.
|
|
194
|
+
*
|
|
195
|
+
* The renderer's `PipelineCache` is not reachable from a contributed pass, and the chain builder
|
|
196
|
+
* takes an interface for exactly that reason. A `Map` is the whole of what is needed: there are at
|
|
197
|
+
* most two formats here, and they are built once each.
|
|
198
|
+
*/
|
|
199
|
+
function mipPipelineCache(device: GPUDevice): MipPipelines {
|
|
200
|
+
const built = new Map<string, GPURenderPipeline>();
|
|
201
|
+
return {
|
|
202
|
+
get(key: string, describe: () => GPURenderPipelineDescriptor): GPURenderPipeline {
|
|
203
|
+
const existing = built.get(key);
|
|
204
|
+
if (existing !== undefined) return existing;
|
|
205
|
+
const pipeline = device.createRenderPipeline(describe());
|
|
206
|
+
built.set(key, pipeline);
|
|
207
|
+
return pipeline;
|
|
208
|
+
},
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
159
212
|
export function setGpuSpriteTexture(
|
|
160
213
|
device: GPUDevice,
|
|
161
214
|
sprites: GpuSprites,
|
|
@@ -165,10 +218,16 @@ export function setGpuSpriteTexture(
|
|
|
165
218
|
): void {
|
|
166
219
|
const previous = sprites.slots[slot];
|
|
167
220
|
if (previous !== null && previous !== undefined) previous.texture.destroy();
|
|
221
|
+
const format: GPUTextureFormat =
|
|
222
|
+
options.colorSpace === 'linear' ? 'rgba8unorm' : 'rgba8unorm-srgb';
|
|
223
|
+
/* One level unless a chain was asked for. `TEXTURE_USAGE` already carries RENDER_ATTACHMENT,
|
|
224
|
+
which is what the blit needs to draw into each level. */
|
|
225
|
+
const levels = options.mipmap === true ? mipLevelCount(source.width, source.height) : 1;
|
|
168
226
|
const texture = device.createTexture({
|
|
169
227
|
label: `ui2d.sprite.${slot}`,
|
|
170
228
|
size: [source.width, source.height],
|
|
171
|
-
format
|
|
229
|
+
format,
|
|
230
|
+
mipLevelCount: levels,
|
|
172
231
|
usage: TEXTURE_USAGE,
|
|
173
232
|
});
|
|
174
233
|
/*
|
|
@@ -182,7 +241,18 @@ export function setGpuSpriteTexture(
|
|
|
182
241
|
{ texture },
|
|
183
242
|
[source.width, source.height],
|
|
184
243
|
);
|
|
185
|
-
|
|
244
|
+
/* After the copy, which is the only order that works: every level is rendered from the one above
|
|
245
|
+
it, and level 0 has to hold the image before the chain can be built from it. */
|
|
246
|
+
generateMipChain(device, sprites.mipPipelines, texture, format, levels);
|
|
247
|
+
const linear = options.filter === 'linear';
|
|
248
|
+
const sampler =
|
|
249
|
+
levels > 1
|
|
250
|
+
? linear
|
|
251
|
+
? sprites.samplers.linearMip
|
|
252
|
+
: sprites.samplers.nearestMip
|
|
253
|
+
: linear
|
|
254
|
+
? sprites.samplers.linear
|
|
255
|
+
: sprites.samplers.nearest;
|
|
186
256
|
const bindGroup = device.createBindGroup({
|
|
187
257
|
label: `ui2d.sprite.${slot}.bindGroup`,
|
|
188
258
|
layout: sprites.layout,
|
package/src/spriteTexture.ts
CHANGED
|
@@ -34,9 +34,40 @@ export interface SpriteTextureOptions {
|
|
|
34
34
|
* and saves the sharp case from a blur nobody asked for.
|
|
35
35
|
*/
|
|
36
36
|
readonly filter?: 'nearest' | 'linear';
|
|
37
|
+
/**
|
|
38
|
+
* Whether to build a mip chain and sample it. `false` by default, which is the sheet above.
|
|
39
|
+
*
|
|
40
|
+
* **The case it exists for is type baked into an atlas.** The comment on `filter` says a sheet
|
|
41
|
+
* that is not pixel art "is normally drawn near its authored size, where the two filters differ
|
|
42
|
+
* by very little" — a glyph page is the sheet where that is not true. A consumer baking one page
|
|
43
|
+
* per weight at 96 px and drawing body copy at 11 is minifying **7x**, and `linear` reads four
|
|
44
|
+
* texels of a footprint that covers dozens: a `t` crossbar two texels tall lands on about a
|
|
45
|
+
* quarter of a pixel, and whether it survives depends on where the sample falls. Reported by a
|
|
46
|
+
* player as `Step-In Uppercut` reading `Slep-In Uppercul`, with different strokes lost per
|
|
47
|
+
* glyph and per position, so the line looks unevenly spaced as well as misread.
|
|
48
|
+
*
|
|
49
|
+
* Neither workaround available to a consumer is good: baking nearer the drawn size blurs the
|
|
50
|
+
* headings, because one atlas cannot serve an 8x range of sizes, and baking a second atlas for
|
|
51
|
+
* small text doubles the pages and the uploads and moves a sampling decision into the game where
|
|
52
|
+
* it has to be re-tuned whenever a size changes.
|
|
53
|
+
*
|
|
54
|
+
* **Off by default because the chain is wrong for pixel art**, which is what most sheets are: it
|
|
55
|
+
* is memory nothing samples, and at a distance it dissolves art whose whole point is the pixel.
|
|
56
|
+
*
|
|
57
|
+
* With `filter: 'linear'` this is trilinear — blended within a level and between levels. With
|
|
58
|
+
* `filter: 'nearest'` the levels are still blended, because that is minification and `filter` is
|
|
59
|
+
* about magnification; `SurfaceTexture` makes the same split and for the same reason.
|
|
60
|
+
*
|
|
61
|
+
* **Padding cells against bleed is the caller's problem**, and it has to be: a lower level mixes
|
|
62
|
+
* texels the atlas packer put next to each other, so a sheet whose frames touch will show its
|
|
63
|
+
* neighbours. How much padding depends on how far the chain is allowed to go, which is a fact
|
|
64
|
+
* about the sheet rather than about the sampler.
|
|
65
|
+
*/
|
|
66
|
+
readonly mipmap?: boolean;
|
|
37
67
|
}
|
|
38
68
|
|
|
39
69
|
export const DEFAULT_SPRITE_TEXTURE_OPTIONS: SpriteTextureOptions = {
|
|
40
70
|
colorSpace: 'srgb',
|
|
41
71
|
filter: 'nearest',
|
|
72
|
+
mipmap: false,
|
|
42
73
|
};
|