@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 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 filter = options.filter === 'linear' ? gl.LINEAR : gl.NEAREST;
101
- gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, filter);
102
- gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, filter);
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;
@@ -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: options.colorSpace === 'linear' ? 'rgba8unorm' : 'rgba8unorm-srgb',
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
- const sampler = options.filter === 'linear' ? sprites.samplers.linear : sprites.samplers.nearest;
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,
@@ -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;
@@ -2,4 +2,5 @@
2
2
  export const DEFAULT_SPRITE_TEXTURE_OPTIONS = {
3
3
  colorSpace: 'srgb',
4
4
  filter: 'nearest',
5
+ mipmap: false,
5
6
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@driftengine/ui2d",
3
- "version": "3.62.0",
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.62.0"
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 filter = options.filter === 'linear' ? gl.LINEAR : gl.NEAREST;
133
- gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, filter);
134
- gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, filter);
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: { readonly nearest: GPUSampler; readonly linear: GPUSampler };
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: options.colorSpace === 'linear' ? 'rgba8unorm' : 'rgba8unorm-srgb',
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
- const sampler = options.filter === 'linear' ? sprites.samplers.linear : sprites.samplers.nearest;
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,
@@ -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
  };