@arcadekit/render-three 0.0.0-stage → 0.2.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 +21 -0
- package/README.md +72 -2
- package/dist/index.d.ts +374 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +855 -0
- package/dist/index.js.map +1 -0
- package/package.json +60 -3
- package/src/camera.ts +104 -0
- package/src/index.ts +43 -0
- package/src/instanced-sprites.ts +330 -0
- package/src/mesh-instances.ts +79 -0
- package/src/pixel-camera.ts +196 -0
- package/src/sprite-loader.ts +103 -0
- package/src/sprite-sheet.ts +312 -0
- package/src/stage.ts +262 -0
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
import * as THREE from "three";
|
|
2
|
+
|
|
3
|
+
import type { SpriteSheet } from "./sprite-loader";
|
|
4
|
+
import type { SpriteSheetData } from "./sprite-sheet";
|
|
5
|
+
|
|
6
|
+
/** Frames per row of the frame table texture; each frame takes 2 texels in a column. */
|
|
7
|
+
const TABLE_COLUMNS = 256;
|
|
8
|
+
|
|
9
|
+
/** How far a layer's depth band reaches either side of its middle. Bands are 1 apart. */
|
|
10
|
+
const BAND = 0.45;
|
|
11
|
+
|
|
12
|
+
const VERTEX = `
|
|
13
|
+
// x, z, lift, layer
|
|
14
|
+
attribute vec4 aPlace;
|
|
15
|
+
// frame, flipX, flash
|
|
16
|
+
attribute vec3 aFrame;
|
|
17
|
+
// r, g, b, alpha
|
|
18
|
+
attribute vec4 aTint;
|
|
19
|
+
uniform sampler2D uFrames;
|
|
20
|
+
uniform vec2 uSheetSize;
|
|
21
|
+
uniform vec2 uAnchor;
|
|
22
|
+
uniform float uUnitsPerPixel;
|
|
23
|
+
uniform float uSnap;
|
|
24
|
+
uniform float uLayer;
|
|
25
|
+
uniform float uDepthScale;
|
|
26
|
+
varying vec2 vUv;
|
|
27
|
+
varying vec4 vTint;
|
|
28
|
+
varying float vFlash;
|
|
29
|
+
|
|
30
|
+
ivec2 tableCell(int frame, int row) {
|
|
31
|
+
return ivec2(frame % ${TABLE_COLUMNS}, (frame / ${TABLE_COLUMNS}) * 2 + row);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
void main() {
|
|
35
|
+
int frame = int(aFrame.x + 0.5);
|
|
36
|
+
// x, y, width, height in the image
|
|
37
|
+
vec4 rect = texelFetch(uFrames, tableCell(frame, 0), 0);
|
|
38
|
+
// offset x, y in the untrimmed frame; its width, height
|
|
39
|
+
vec4 source = texelFetch(uFrames, tableCell(frame, 1), 0);
|
|
40
|
+
// uv.y is 1 at the top of the quad; pixel rows count down from the top.
|
|
41
|
+
vec2 corner = vec2(uv.x, 1.0 - uv.y);
|
|
42
|
+
vec2 pixel = source.xy + corner * rect.zw;
|
|
43
|
+
if (aFrame.y > 0.5) {
|
|
44
|
+
pixel.x = source.z - pixel.x;
|
|
45
|
+
}
|
|
46
|
+
vec2 anchor = floor(uAnchor * source.zw + 0.5);
|
|
47
|
+
vec2 local = (pixel - anchor) * uUnitsPerPixel;
|
|
48
|
+
// Up the screen is -z, so a lifted sprite moves toward -z; its depth stays at its feet.
|
|
49
|
+
vec2 base = vec2(aPlace.x, aPlace.y - aPlace.z);
|
|
50
|
+
if (uSnap > 0.5) {
|
|
51
|
+
base = floor(base / uUnitsPerPixel + 0.5) * uUnitsPerPixel;
|
|
52
|
+
}
|
|
53
|
+
float depth = uLayer + aPlace.w + 0.5 + clamp(aPlace.y * uDepthScale, -${BAND}, ${BAND});
|
|
54
|
+
vUv = (rect.xy + corner * rect.zw) / uSheetSize;
|
|
55
|
+
vTint = aTint;
|
|
56
|
+
vFlash = aFrame.z;
|
|
57
|
+
gl_Position = projectionMatrix * viewMatrix * modelMatrix * vec4(base.x + local.x, depth, base.y + local.y, 1.0);
|
|
58
|
+
}`;
|
|
59
|
+
|
|
60
|
+
const FRAGMENT = `
|
|
61
|
+
uniform sampler2D uMap;
|
|
62
|
+
uniform float uAlphaTest;
|
|
63
|
+
uniform float uBlend;
|
|
64
|
+
varying vec2 vUv;
|
|
65
|
+
varying vec4 vTint;
|
|
66
|
+
varying float vFlash;
|
|
67
|
+
|
|
68
|
+
void main() {
|
|
69
|
+
vec4 texel = texture2D(uMap, vUv);
|
|
70
|
+
float alpha = texel.a * vTint.a;
|
|
71
|
+
if (alpha < uAlphaTest) discard;
|
|
72
|
+
vec3 color = mix(texel.rgb * vTint.rgb, vec3(1.0), vFlash);
|
|
73
|
+
gl_FragColor = vec4(color, uBlend > 0.5 ? alpha : 1.0);
|
|
74
|
+
#include <colorspace_fragment>
|
|
75
|
+
}`;
|
|
76
|
+
|
|
77
|
+
export interface InstancedSpritesOptions {
|
|
78
|
+
/** The most sprites it can draw at once. Fixed. */
|
|
79
|
+
readonly capacity: number;
|
|
80
|
+
/** World units per sprite pixel: 1/16 draws a 16 px sprite one unit wide. Defaults to 1/16. */
|
|
81
|
+
readonly unitsPerPixel?: number;
|
|
82
|
+
/**
|
|
83
|
+
* The point of a frame a sprite's position marks, as a fraction of the frame's untrimmed size
|
|
84
|
+
* from its top-left corner, rounded to a whole pixel. Defaults to the middle, (0.5, 0.5); a
|
|
85
|
+
* character standing on its feet wants about (0.5, 0.9).
|
|
86
|
+
*/
|
|
87
|
+
readonly anchor?: { readonly x: number; readonly y: number };
|
|
88
|
+
/**
|
|
89
|
+
* Layer added to every sprite's own layer. A higher layer draws over a lower one whatever
|
|
90
|
+
* their positions: ground marks below characters, characters below projectiles. Use whole
|
|
91
|
+
* numbers; layer 0 and up draw above anything at y = 0, such as a ground mesh. Defaults to 0.
|
|
92
|
+
*/
|
|
93
|
+
readonly layer?: number;
|
|
94
|
+
/**
|
|
95
|
+
* Blend partly transparent pixels, for soft shadows and markers. Off by default: pixel art
|
|
96
|
+
* draws opaque, with pixels below `alphaTest` cut out, and sorts by depth. A transparent batch
|
|
97
|
+
* doesn't write depth, so its own sprites overlap in index order.
|
|
98
|
+
*/
|
|
99
|
+
readonly transparent?: boolean;
|
|
100
|
+
/** Pixels with less alpha (after the sprite's own alpha) aren't drawn. Defaults to 0.5, or 0.01 when transparent. */
|
|
101
|
+
readonly alphaTest?: number;
|
|
102
|
+
/** Rounds each sprite's position to whole sprite pixels, so the art never shimmers. Defaults to true. */
|
|
103
|
+
readonly snap?: boolean;
|
|
104
|
+
/**
|
|
105
|
+
* How far from z = 0, in world units, depth still orders sprites within a layer: beyond it,
|
|
106
|
+
* sprites tie. Defaults to 256.
|
|
107
|
+
*/
|
|
108
|
+
readonly sortRange?: number;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Draws up to `capacity` sprites from one sheet in a single draw call, as quads on the ground
|
|
113
|
+
* plane facing a straight-down camera (`PixelCamera`). Each sprite has a position, a frame,
|
|
114
|
+
* a horizontal flip, a tint, a flash toward white, an alpha, a lift and a layer. Within a layer
|
|
115
|
+
* a sprite lower on screen (larger z) draws in front.
|
|
116
|
+
*
|
|
117
|
+
* Each frame, call `set` for every sprite to draw (it resets that sprite's look), then the
|
|
118
|
+
* other setters for those that need them, then `commit(count)`. Nothing allocates per frame.
|
|
119
|
+
* Add `mesh` to the scene. `dispose` (or `disposeObject`) frees its own geometry, material and
|
|
120
|
+
* frame table, never the sheet's texture: the sheet's owner frees that.
|
|
121
|
+
*/
|
|
122
|
+
export class InstancedSprites {
|
|
123
|
+
readonly mesh: THREE.Mesh<
|
|
124
|
+
THREE.InstancedBufferGeometry,
|
|
125
|
+
THREE.ShaderMaterial
|
|
126
|
+
>;
|
|
127
|
+
readonly capacity: number;
|
|
128
|
+
readonly sheet: SpriteSheet;
|
|
129
|
+
private readonly frameCount: number;
|
|
130
|
+
private readonly place: THREE.InstancedBufferAttribute;
|
|
131
|
+
private readonly frame: THREE.InstancedBufferAttribute;
|
|
132
|
+
private readonly tint: THREE.InstancedBufferAttribute;
|
|
133
|
+
private readonly frameTable: THREE.DataTexture;
|
|
134
|
+
|
|
135
|
+
constructor(sheet: SpriteSheet, options: InstancedSpritesOptions) {
|
|
136
|
+
this.sheet = sheet;
|
|
137
|
+
this.capacity = options.capacity;
|
|
138
|
+
this.frameCount = sheet.data.frames.length;
|
|
139
|
+
const transparent = options.transparent ?? false;
|
|
140
|
+
|
|
141
|
+
const quad = new THREE.PlaneGeometry(1, 1);
|
|
142
|
+
const geometry = new THREE.InstancedBufferGeometry();
|
|
143
|
+
geometry.index = quad.index;
|
|
144
|
+
geometry.setAttribute("position", quad.getAttribute("position"));
|
|
145
|
+
geometry.setAttribute("uv", quad.getAttribute("uv"));
|
|
146
|
+
quad.dispose();
|
|
147
|
+
this.place = dynamicAttribute(geometry, "aPlace", this.capacity, 4);
|
|
148
|
+
this.frame = dynamicAttribute(geometry, "aFrame", this.capacity, 3);
|
|
149
|
+
this.tint = dynamicAttribute(geometry, "aTint", this.capacity, 4);
|
|
150
|
+
geometry.instanceCount = 0;
|
|
151
|
+
|
|
152
|
+
this.frameTable = frameTable(sheet.data);
|
|
153
|
+
const anchor = options.anchor ?? { x: 0.5, y: 0.5 };
|
|
154
|
+
const material = new THREE.ShaderMaterial({
|
|
155
|
+
vertexShader: VERTEX,
|
|
156
|
+
fragmentShader: FRAGMENT,
|
|
157
|
+
uniforms: {
|
|
158
|
+
uMap: { value: sheet.texture },
|
|
159
|
+
uFrames: { value: this.frameTable },
|
|
160
|
+
uSheetSize: {
|
|
161
|
+
value: new THREE.Vector2(sheet.data.width, sheet.data.height),
|
|
162
|
+
},
|
|
163
|
+
uAnchor: { value: new THREE.Vector2(anchor.x, anchor.y) },
|
|
164
|
+
uUnitsPerPixel: { value: options.unitsPerPixel ?? 1 / 16 },
|
|
165
|
+
uSnap: { value: (options.snap ?? true) ? 1 : 0 },
|
|
166
|
+
uLayer: { value: options.layer ?? 0 },
|
|
167
|
+
uDepthScale: { value: BAND / (options.sortRange ?? 256) },
|
|
168
|
+
uAlphaTest: {
|
|
169
|
+
value: options.alphaTest ?? (transparent ? 0.01 : 0.5),
|
|
170
|
+
},
|
|
171
|
+
uBlend: { value: transparent ? 1 : 0 },
|
|
172
|
+
},
|
|
173
|
+
transparent,
|
|
174
|
+
depthWrite: !transparent,
|
|
175
|
+
// A flipped sprite mirrors its quad, which turns it face down.
|
|
176
|
+
side: THREE.DoubleSide,
|
|
177
|
+
});
|
|
178
|
+
this.mesh = new THREE.Mesh(geometry, material);
|
|
179
|
+
// Sprites can be anywhere, so the quad's bounds mean nothing: never cull.
|
|
180
|
+
this.mesh.frustumCulled = false;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Places sprite `index` with its feet (its anchor) at (x, z) on the ground, showing frame
|
|
185
|
+
* `frame` (an index into `sheet.data.frames`, as `frameAt` gives), mirrored if `flipX`.
|
|
186
|
+
* Resets its tint, alpha, flash, lift and layer. A frame outside the sheet draws frame 0.
|
|
187
|
+
*/
|
|
188
|
+
set(index: number, x: number, z: number, frame: number, flipX = false): void {
|
|
189
|
+
const shown = frame >= 0 && frame < this.frameCount ? frame : 0;
|
|
190
|
+
write4(this.place, index, x, z, 0, 0);
|
|
191
|
+
write3(this.frame, index, shown, flipX ? 1 : 0, 0);
|
|
192
|
+
write4(this.tint, index, 1, 1, 1, 1);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Multiplies sprite `index`'s colors by `color` (white leaves them), and sets its alpha. */
|
|
196
|
+
setTint(index: number, color: THREE.Color, alpha = 1): void {
|
|
197
|
+
write4(this.tint, index, color.r, color.g, color.b, alpha);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Sets sprite `index`'s alpha (0 to 1) and keeps its tint. Opaque sprites hide below `alphaTest`. */
|
|
201
|
+
setAlpha(index: number, alpha: number): void {
|
|
202
|
+
(this.tint.array as Float32Array)[index * 4 + 3] = alpha;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Blends sprite `index` toward white: 0 is its own colors, 1 a white silhouette. A hit flash. */
|
|
206
|
+
setFlash(index: number, amount: number): void {
|
|
207
|
+
(this.frame.array as Float32Array)[index * 3 + 2] = amount;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Draws sprite `index` `lift` world units up the screen, as if floating: a bat. Its depth
|
|
212
|
+
* stays where its feet are, so it sorts with the things standing near it.
|
|
213
|
+
*/
|
|
214
|
+
setLift(index: number, lift: number): void {
|
|
215
|
+
(this.place.array as Float32Array)[index * 4 + 2] = lift;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Puts sprite `index` in another layer, added to the batch's own. See `InstancedSpritesOptions.layer`. */
|
|
219
|
+
setLayer(index: number, layer: number): void {
|
|
220
|
+
(this.place.array as Float32Array)[index * 4 + 3] = layer;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Shows the first `count` sprites and uploads them. */
|
|
224
|
+
commit(count: number): void {
|
|
225
|
+
const shown = Math.max(0, Math.min(count, this.capacity));
|
|
226
|
+
this.mesh.geometry.instanceCount = shown;
|
|
227
|
+
for (const attribute of [this.place, this.frame, this.tint]) {
|
|
228
|
+
attribute.clearUpdateRanges();
|
|
229
|
+
attribute.addUpdateRange(0, Math.max(1, shown) * attribute.itemSize);
|
|
230
|
+
attribute.needsUpdate = true;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Removes the mesh and frees its geometry, material and frame table. Never the sheet. */
|
|
235
|
+
dispose(): void {
|
|
236
|
+
this.mesh.removeFromParent();
|
|
237
|
+
this.mesh.geometry.dispose();
|
|
238
|
+
this.mesh.material.dispose();
|
|
239
|
+
this.frameTable.dispose();
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const dynamicAttribute = (
|
|
244
|
+
geometry: THREE.InstancedBufferGeometry,
|
|
245
|
+
name: string,
|
|
246
|
+
capacity: number,
|
|
247
|
+
size: number
|
|
248
|
+
): THREE.InstancedBufferAttribute => {
|
|
249
|
+
const attribute = new THREE.InstancedBufferAttribute(
|
|
250
|
+
new Float32Array(Math.max(1, capacity) * size),
|
|
251
|
+
size
|
|
252
|
+
);
|
|
253
|
+
attribute.setUsage(THREE.DynamicDrawUsage);
|
|
254
|
+
geometry.setAttribute(name, attribute);
|
|
255
|
+
return attribute;
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
const write4 = (
|
|
259
|
+
attribute: THREE.InstancedBufferAttribute,
|
|
260
|
+
index: number,
|
|
261
|
+
a: number,
|
|
262
|
+
b: number,
|
|
263
|
+
c: number,
|
|
264
|
+
d: number
|
|
265
|
+
) => {
|
|
266
|
+
const array = attribute.array as Float32Array;
|
|
267
|
+
const at = index * 4;
|
|
268
|
+
array[at] = a;
|
|
269
|
+
array[at + 1] = b;
|
|
270
|
+
array[at + 2] = c;
|
|
271
|
+
array[at + 3] = d;
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
const write3 = (
|
|
275
|
+
attribute: THREE.InstancedBufferAttribute,
|
|
276
|
+
index: number,
|
|
277
|
+
a: number,
|
|
278
|
+
b: number,
|
|
279
|
+
c: number
|
|
280
|
+
) => {
|
|
281
|
+
const array = attribute.array as Float32Array;
|
|
282
|
+
const at = index * 3;
|
|
283
|
+
array[at] = a;
|
|
284
|
+
array[at + 1] = b;
|
|
285
|
+
array[at + 2] = c;
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* The frame table the vertex shader reads: frame i at column i % TABLE_COLUMNS, rows
|
|
290
|
+
* 2 * floor(i / TABLE_COLUMNS) (its rect in the image) and the row after (its offset in the
|
|
291
|
+
* untrimmed frame, and that frame's size).
|
|
292
|
+
*/
|
|
293
|
+
const frameTableData = (
|
|
294
|
+
sheet: SpriteSheetData
|
|
295
|
+
): {
|
|
296
|
+
readonly data: Float32Array;
|
|
297
|
+
readonly width: number;
|
|
298
|
+
readonly height: number;
|
|
299
|
+
} => {
|
|
300
|
+
const count = sheet.frames.length;
|
|
301
|
+
const width = Math.min(Math.max(1, count), TABLE_COLUMNS);
|
|
302
|
+
const height = 2 * Math.max(1, Math.ceil(count / TABLE_COLUMNS));
|
|
303
|
+
const data = new Float32Array(width * height * 4);
|
|
304
|
+
for (const [index, frame] of sheet.frames.entries()) {
|
|
305
|
+
const column = index % TABLE_COLUMNS;
|
|
306
|
+
const row = 2 * Math.floor(index / TABLE_COLUMNS);
|
|
307
|
+
data.set(
|
|
308
|
+
[frame.x, frame.y, frame.width, frame.height],
|
|
309
|
+
(row * width + column) * 4
|
|
310
|
+
);
|
|
311
|
+
data.set(
|
|
312
|
+
[frame.offsetX, frame.offsetY, frame.sourceWidth, frame.sourceHeight],
|
|
313
|
+
((row + 1) * width + column) * 4
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
return { data, width, height };
|
|
317
|
+
};
|
|
318
|
+
|
|
319
|
+
const frameTable = (sheet: SpriteSheetData): THREE.DataTexture => {
|
|
320
|
+
const { data, width, height } = frameTableData(sheet);
|
|
321
|
+
const texture = new THREE.DataTexture(
|
|
322
|
+
data,
|
|
323
|
+
width,
|
|
324
|
+
height,
|
|
325
|
+
THREE.RGBAFormat,
|
|
326
|
+
THREE.FloatType
|
|
327
|
+
);
|
|
328
|
+
texture.needsUpdate = true;
|
|
329
|
+
return texture;
|
|
330
|
+
};
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import * as THREE from "three";
|
|
2
|
+
|
|
3
|
+
const DEFAULT_COLOR = new THREE.Color("#ffffff");
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Draws up to `capacity` copies of one mesh in a single draw call.
|
|
7
|
+
* Call `set` (and optionally `setColor`) for the instances that changed, then `commit(count)`
|
|
8
|
+
* to show the first `count` instances and upload the changes.
|
|
9
|
+
*/
|
|
10
|
+
export class MeshInstances {
|
|
11
|
+
readonly mesh: THREE.InstancedMesh;
|
|
12
|
+
readonly capacity: number;
|
|
13
|
+
private readonly matrix = new THREE.Matrix4();
|
|
14
|
+
|
|
15
|
+
constructor(
|
|
16
|
+
geometry: THREE.BufferGeometry,
|
|
17
|
+
material: THREE.Material,
|
|
18
|
+
capacity: number
|
|
19
|
+
) {
|
|
20
|
+
this.capacity = capacity;
|
|
21
|
+
this.mesh = new THREE.InstancedMesh(geometry, material, capacity);
|
|
22
|
+
this.mesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
|
|
23
|
+
// Allocates the per-instance color buffer.
|
|
24
|
+
this.mesh.setColorAt(0, DEFAULT_COLOR);
|
|
25
|
+
this.mesh.count = 0;
|
|
26
|
+
// Instances can be anywhere, so the mesh's bounding sphere means nothing: never cull.
|
|
27
|
+
this.mesh.frustumCulled = false;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Places instance `index` at (x, y, z) with a uniform `scale` and a rotation around the vertical axis.
|
|
32
|
+
* For a fixed tilt every instance shares (a flat ring lying on the floor), rotate the geometry once instead.
|
|
33
|
+
*/
|
|
34
|
+
set(
|
|
35
|
+
index: number,
|
|
36
|
+
x: number,
|
|
37
|
+
y: number,
|
|
38
|
+
z: number,
|
|
39
|
+
scale = 1,
|
|
40
|
+
rotationY = 0
|
|
41
|
+
): void {
|
|
42
|
+
if (rotationY === 0) {
|
|
43
|
+
this.matrix.makeScale(scale, scale, scale);
|
|
44
|
+
} else {
|
|
45
|
+
this.matrix.makeRotationY(rotationY);
|
|
46
|
+
if (scale !== 1) {
|
|
47
|
+
this.matrix.scale(SCALE.set(scale, scale, scale));
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
this.matrix.setPosition(x, y, z);
|
|
51
|
+
this.mesh.setMatrixAt(index, this.matrix);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
setColor(index: number, color: THREE.Color): void {
|
|
55
|
+
this.mesh.setColorAt(index, color);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Shows the first `count` instances and uploads this frame's changes. */
|
|
59
|
+
commit(count: number): void {
|
|
60
|
+
this.mesh.count = Math.min(count, this.capacity);
|
|
61
|
+
this.mesh.instanceMatrix.needsUpdate = true;
|
|
62
|
+
if (this.mesh.instanceColor) {
|
|
63
|
+
this.mesh.instanceColor.needsUpdate = true;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
dispose(): void {
|
|
68
|
+
this.mesh.removeFromParent();
|
|
69
|
+
this.mesh.geometry.dispose();
|
|
70
|
+
const materials: THREE.Material[] = Array.isArray(this.mesh.material)
|
|
71
|
+
? this.mesh.material
|
|
72
|
+
: [this.mesh.material];
|
|
73
|
+
for (const material of materials) {
|
|
74
|
+
material.dispose();
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const SCALE = new THREE.Vector3();
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
// A straight-down orthographic camera for pixel art. Every sprite pixel covers a whole number of
|
|
2
|
+
// screen pixels, and the camera only ever moves by whole screen pixels, so the art never shimmers.
|
|
3
|
+
|
|
4
|
+
import * as THREE from "three";
|
|
5
|
+
|
|
6
|
+
import type { GroundBounds } from "./camera";
|
|
7
|
+
|
|
8
|
+
/** How high above the ground the camera sits. Anything from y = -HEIGHT to just below it is drawn. */
|
|
9
|
+
const HEIGHT = 100;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Screen pixels per sprite pixel that best shows `viewHeight` world units on a drawing buffer
|
|
13
|
+
* `bufferHeight` pixels tall: the nearest whole number, at least 1.
|
|
14
|
+
*/
|
|
15
|
+
export const pixelScale = (
|
|
16
|
+
bufferHeight: number,
|
|
17
|
+
viewHeight: number,
|
|
18
|
+
pixelsPerUnit: number
|
|
19
|
+
): number =>
|
|
20
|
+
Math.max(1, Math.round(bufferHeight / (viewHeight * pixelsPerUnit)));
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The largest whole number of screen pixels per sprite pixel at which a `width` x `depth` world
|
|
24
|
+
* unit area fits a `bufferWidth` x `bufferHeight` drawing buffer, at least 1 (a buffer too
|
|
25
|
+
* small for the area at 1 crops it).
|
|
26
|
+
*/
|
|
27
|
+
export const fitPixelScale = (
|
|
28
|
+
bufferWidth: number,
|
|
29
|
+
bufferHeight: number,
|
|
30
|
+
width: number,
|
|
31
|
+
depth: number,
|
|
32
|
+
pixelsPerUnit: number
|
|
33
|
+
): number =>
|
|
34
|
+
Math.max(
|
|
35
|
+
1,
|
|
36
|
+
Math.floor(
|
|
37
|
+
Math.min(
|
|
38
|
+
bufferWidth / (width * pixelsPerUnit),
|
|
39
|
+
bufferHeight / (depth * pixelsPerUnit)
|
|
40
|
+
)
|
|
41
|
+
)
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Rounds a world position to the nearest pixel edge, at `pixelsPerUnit` pixels per world unit:
|
|
46
|
+
* sprite pixels with the sheet's density, or screen pixels with the camera's.
|
|
47
|
+
*/
|
|
48
|
+
export const snapToPixel = (value: number, pixelsPerUnit: number): number =>
|
|
49
|
+
Math.round(value * pixelsPerUnit) / pixelsPerUnit;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The frustum for a `bufferWidth` x `bufferHeight` drawing buffer at `pixelsPerWorldUnit`
|
|
53
|
+
* screen pixels per world unit, around a camera at the origin. An odd size gets its spare
|
|
54
|
+
* pixel on the right or bottom, so the camera's position stays on a screen pixel's corner.
|
|
55
|
+
*/
|
|
56
|
+
export const pixelFrustum = (
|
|
57
|
+
bufferWidth: number,
|
|
58
|
+
bufferHeight: number,
|
|
59
|
+
pixelsPerWorldUnit: number
|
|
60
|
+
): {
|
|
61
|
+
readonly left: number;
|
|
62
|
+
readonly right: number;
|
|
63
|
+
readonly top: number;
|
|
64
|
+
readonly bottom: number;
|
|
65
|
+
} => {
|
|
66
|
+
const halfWidth = Math.floor(bufferWidth / 2);
|
|
67
|
+
const halfHeight = Math.floor(bufferHeight / 2);
|
|
68
|
+
return {
|
|
69
|
+
left: -halfWidth / pixelsPerWorldUnit,
|
|
70
|
+
right: (bufferWidth - halfWidth) / pixelsPerWorldUnit,
|
|
71
|
+
top: halfHeight / pixelsPerWorldUnit,
|
|
72
|
+
bottom: -(bufferHeight - halfHeight) / pixelsPerWorldUnit,
|
|
73
|
+
};
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
export interface PixelCameraOptions {
|
|
77
|
+
/** Sprite pixels per world unit: 16 draws a 16 px enemy one unit wide. Defaults to 16. */
|
|
78
|
+
readonly pixelsPerUnit?: number;
|
|
79
|
+
/**
|
|
80
|
+
* About how many world units of ground to show top to bottom. The camera picks the whole
|
|
81
|
+
* number of screen pixels per sprite pixel that comes nearest. Defaults to 20.
|
|
82
|
+
*/
|
|
83
|
+
readonly viewHeight?: number;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* An orthographic camera looking straight down at the ground plane (y = 0), with +x to the
|
|
88
|
+
* right and +z down the screen, so a larger z is lower on screen. It keeps sprite pixels whole:
|
|
89
|
+
* `pixelScale` screen pixels per sprite pixel, a whole number, and a position on whole screen
|
|
90
|
+
* pixels. Give it the drawing buffer's size with `setBufferSize` (a `Stage` made with
|
|
91
|
+
* `camera: "pixel"` does, on every resize), then `follow` a point or `fit` an area each frame.
|
|
92
|
+
* Don't move it with `fitCamera` or `lookAt`: they undo the snapping.
|
|
93
|
+
*/
|
|
94
|
+
export class PixelCamera extends THREE.OrthographicCamera {
|
|
95
|
+
readonly isPixelCamera = true;
|
|
96
|
+
private pixelsPerUnitValue: number;
|
|
97
|
+
private viewHeightValue: number;
|
|
98
|
+
private screenScale = 1;
|
|
99
|
+
private bufferWidth = 1;
|
|
100
|
+
private bufferHeight = 1;
|
|
101
|
+
private targetX = 0;
|
|
102
|
+
private targetZ = 0;
|
|
103
|
+
/** Set by `fit`, cleared by `follow`. */
|
|
104
|
+
private fitted: GroundBounds | null = null;
|
|
105
|
+
|
|
106
|
+
constructor(options: PixelCameraOptions = {}) {
|
|
107
|
+
super(-1, 1, 1, -1, 1, 2 * HEIGHT);
|
|
108
|
+
this.pixelsPerUnitValue = options.pixelsPerUnit ?? 16;
|
|
109
|
+
this.viewHeightValue = options.viewHeight ?? 20;
|
|
110
|
+
// Straight down, with up on screen toward -z. Set directly: lookAt would tilt it a hair.
|
|
111
|
+
this.rotation.set(-Math.PI / 2, 0, 0);
|
|
112
|
+
this.update();
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Sprite pixels per world unit. */
|
|
116
|
+
get pixelsPerUnit(): number {
|
|
117
|
+
return this.pixelsPerUnitValue;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Screen pixels per sprite pixel: a whole number, at least 1. */
|
|
121
|
+
get pixelScale(): number {
|
|
122
|
+
return this.screenScale;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Screen (drawing buffer) pixels per world unit. */
|
|
126
|
+
get screenPixelsPerUnit(): number {
|
|
127
|
+
return this.pixelsPerUnitValue * this.screenScale;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** The ground height to aim for, in world units. Takes effect at once. */
|
|
131
|
+
setViewHeight(viewHeight: number): void {
|
|
132
|
+
this.viewHeightValue = viewHeight;
|
|
133
|
+
this.update();
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** The drawing buffer's size in pixels (`renderer.getDrawingBufferSize`), not CSS pixels. */
|
|
137
|
+
setBufferSize(width: number, height: number): void {
|
|
138
|
+
this.bufferWidth = Math.max(1, Math.round(width));
|
|
139
|
+
this.bufferHeight = Math.max(1, Math.round(height));
|
|
140
|
+
this.update();
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Centres the view on a ground point, snapped to the nearest screen pixel. Survivors' hero. */
|
|
144
|
+
follow(x: number, z: number): void {
|
|
145
|
+
this.fitted = null;
|
|
146
|
+
this.targetX = x;
|
|
147
|
+
this.targetZ = z;
|
|
148
|
+
this.update();
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Shows all of an area plus `margin` world units around it, at the largest whole pixel scale
|
|
153
|
+
* that fits, centred on it. It keeps fitting on resize until the next `follow`. A puzzle board.
|
|
154
|
+
*/
|
|
155
|
+
fit(bounds: GroundBounds, margin = 0): void {
|
|
156
|
+
this.fitted = {
|
|
157
|
+
minX: bounds.minX - margin,
|
|
158
|
+
maxX: bounds.maxX + margin,
|
|
159
|
+
minZ: bounds.minZ - margin,
|
|
160
|
+
maxZ: bounds.maxZ + margin,
|
|
161
|
+
};
|
|
162
|
+
this.targetX = (bounds.minX + bounds.maxX) / 2;
|
|
163
|
+
this.targetZ = (bounds.minZ + bounds.maxZ) / 2;
|
|
164
|
+
this.update();
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
private update(): void {
|
|
168
|
+
const perUnit = this.pixelsPerUnitValue;
|
|
169
|
+
this.screenScale = this.fitted
|
|
170
|
+
? fitPixelScale(
|
|
171
|
+
this.bufferWidth,
|
|
172
|
+
this.bufferHeight,
|
|
173
|
+
this.fitted.maxX - this.fitted.minX,
|
|
174
|
+
this.fitted.maxZ - this.fitted.minZ,
|
|
175
|
+
perUnit
|
|
176
|
+
)
|
|
177
|
+
: pixelScale(this.bufferHeight, this.viewHeightValue, perUnit);
|
|
178
|
+
const screenPerUnit = perUnit * this.screenScale;
|
|
179
|
+
const frustum = pixelFrustum(
|
|
180
|
+
this.bufferWidth,
|
|
181
|
+
this.bufferHeight,
|
|
182
|
+
screenPerUnit
|
|
183
|
+
);
|
|
184
|
+
this.left = frustum.left;
|
|
185
|
+
this.right = frustum.right;
|
|
186
|
+
this.top = frustum.top;
|
|
187
|
+
this.bottom = frustum.bottom;
|
|
188
|
+
this.updateProjectionMatrix();
|
|
189
|
+
this.position.set(
|
|
190
|
+
snapToPixel(this.targetX, screenPerUnit),
|
|
191
|
+
HEIGHT,
|
|
192
|
+
snapToPixel(this.targetZ, screenPerUnit)
|
|
193
|
+
);
|
|
194
|
+
this.updateMatrixWorld();
|
|
195
|
+
}
|
|
196
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
import * as THREE from "three";
|
|
3
|
+
|
|
4
|
+
import { SpriteSheetError, decodeSpriteSheet } from "./sprite-sheet";
|
|
5
|
+
import type { SpriteSheetData } from "./sprite-sheet";
|
|
6
|
+
import { keepTexture } from "./stage";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* A sprite sheet ready to draw: its frames and tags, and its image as a texture.
|
|
10
|
+
* The app loads it and owns it: views draw from it but never free it (`disposeObject` and
|
|
11
|
+
* `Stage.dispose` skip its texture). The app calls `dispose` when it no longer needs it.
|
|
12
|
+
*/
|
|
13
|
+
export interface SpriteSheet {
|
|
14
|
+
readonly data: SpriteSheetData;
|
|
15
|
+
/** Nearest filtering, no mipmaps, sRGB, `flipY` off: row 0 of the image is v = 0. */
|
|
16
|
+
readonly texture: THREE.Texture;
|
|
17
|
+
readonly dispose: () => void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Makes a sheet from parsed data and an image that's already in memory, such as a canvas the
|
|
22
|
+
* art was painted on. `loadSpriteSheet` uses it after fetching both files.
|
|
23
|
+
*/
|
|
24
|
+
export const makeSpriteSheet = (
|
|
25
|
+
data: SpriteSheetData,
|
|
26
|
+
image: TexImageSource
|
|
27
|
+
): SpriteSheet => {
|
|
28
|
+
const texture = new THREE.Texture(image);
|
|
29
|
+
texture.magFilter = THREE.NearestFilter;
|
|
30
|
+
texture.minFilter = THREE.NearestFilter;
|
|
31
|
+
texture.generateMipmaps = false;
|
|
32
|
+
texture.colorSpace = THREE.SRGBColorSpace;
|
|
33
|
+
texture.flipY = false;
|
|
34
|
+
texture.premultiplyAlpha = false;
|
|
35
|
+
texture.name = data.image;
|
|
36
|
+
texture.needsUpdate = true;
|
|
37
|
+
keepTexture(texture);
|
|
38
|
+
return {
|
|
39
|
+
data,
|
|
40
|
+
texture,
|
|
41
|
+
dispose: () => {
|
|
42
|
+
texture.dispose();
|
|
43
|
+
if (typeof ImageBitmap !== "undefined" && image instanceof ImageBitmap) {
|
|
44
|
+
image.close();
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Loads an Aseprite sheet: fetches the JSON at `jsonUrl`, then the image its `meta.image`
|
|
52
|
+
* names, relative to the JSON. Fails with a `SpriteSheetError` naming the file when either
|
|
53
|
+
* can't be fetched, the JSON is invalid, or the image's size isn't `meta.size`.
|
|
54
|
+
*/
|
|
55
|
+
export const loadSpriteSheet = (
|
|
56
|
+
jsonUrl: string
|
|
57
|
+
): Effect.Effect<SpriteSheet, SpriteSheetError> =>
|
|
58
|
+
Effect.gen(function* () {
|
|
59
|
+
const json = yield* fetchFile(
|
|
60
|
+
jsonUrl,
|
|
61
|
+
(response) => response.json() as Promise<unknown>
|
|
62
|
+
);
|
|
63
|
+
const data = yield* decodeSpriteSheet(jsonUrl, json);
|
|
64
|
+
const imageUrl = new URL(
|
|
65
|
+
data.image,
|
|
66
|
+
new URL(jsonUrl, globalThis.location.href)
|
|
67
|
+
).href;
|
|
68
|
+
const image = yield* fetchFile(imageUrl, async (response) =>
|
|
69
|
+
createImageBitmap(await response.blob(), {
|
|
70
|
+
premultiplyAlpha: "none",
|
|
71
|
+
colorSpaceConversion: "none",
|
|
72
|
+
})
|
|
73
|
+
);
|
|
74
|
+
const { width, height } = image;
|
|
75
|
+
if (width !== data.width || height !== data.height) {
|
|
76
|
+
image.close();
|
|
77
|
+
return yield* new SpriteSheetError({
|
|
78
|
+
file: imageUrl,
|
|
79
|
+
message: `The sprite sheet image ${imageUrl} is ${width}x${height}, but ${jsonUrl} says ${data.width}x${data.height}`,
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
return makeSpriteSheet(data, image);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
const fetchFile = <A>(
|
|
86
|
+
file: string,
|
|
87
|
+
read: (response: Response) => Promise<A>
|
|
88
|
+
): Effect.Effect<A, SpriteSheetError> =>
|
|
89
|
+
Effect.tryPromise({
|
|
90
|
+
try: async (signal) => {
|
|
91
|
+
const response = await fetch(file, { signal });
|
|
92
|
+
if (!response.ok) {
|
|
93
|
+
throw new Error(`HTTP ${response.status}`);
|
|
94
|
+
}
|
|
95
|
+
return await read(response);
|
|
96
|
+
},
|
|
97
|
+
catch: (cause) =>
|
|
98
|
+
new SpriteSheetError({
|
|
99
|
+
file,
|
|
100
|
+
message: `The sprite sheet file ${file} can't be loaded: ${cause instanceof Error ? cause.message : String(cause)}`,
|
|
101
|
+
cause,
|
|
102
|
+
}),
|
|
103
|
+
});
|