@arcadekit/render-three 0.0.0-stage → 0.1.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,312 @@
|
|
|
1
|
+
// Sprite sheet data: Aseprite's JSON export, parsed and checked. No DOM and no Three.js here,
|
|
2
|
+
// so it runs anywhere; the loader (sprite-loader.ts) adds the image and its texture.
|
|
3
|
+
|
|
4
|
+
import { Data, Effect, Result, Schema } from "effect";
|
|
5
|
+
|
|
6
|
+
// Named so the type files can be made one file at a time (isolatedDeclarations).
|
|
7
|
+
const SpriteSheetErrorBase: ReturnType<
|
|
8
|
+
typeof Data.TaggedError<"SpriteSheetError">
|
|
9
|
+
> = Data.TaggedError("SpriteSheetError");
|
|
10
|
+
|
|
11
|
+
/** A sprite sheet that couldn't be fetched, decoded or checked. `file` is the JSON or image that failed. */
|
|
12
|
+
export class SpriteSheetError extends SpriteSheetErrorBase<{
|
|
13
|
+
readonly file: string;
|
|
14
|
+
readonly message: string;
|
|
15
|
+
readonly cause?: unknown;
|
|
16
|
+
}> {}
|
|
17
|
+
|
|
18
|
+
/** How a tag's frames play: Aseprite's tag directions. */
|
|
19
|
+
export type SpriteTagDirection =
|
|
20
|
+
| "forward"
|
|
21
|
+
| "reverse"
|
|
22
|
+
| "pingpong"
|
|
23
|
+
| "pingpong_reverse";
|
|
24
|
+
|
|
25
|
+
/** One frame of a sheet. Every size and offset is in sprite pixels. */
|
|
26
|
+
export interface SpriteFrame {
|
|
27
|
+
/** Aseprite's `filename`, or the frame's key in the "hash" form. */
|
|
28
|
+
readonly name: string;
|
|
29
|
+
/** Where the frame's pixels are in the image, from its top-left corner. */
|
|
30
|
+
readonly x: number;
|
|
31
|
+
readonly y: number;
|
|
32
|
+
readonly width: number;
|
|
33
|
+
readonly height: number;
|
|
34
|
+
/** Where those pixels sit in the untrimmed frame. 0, 0 unless the export trimmed it. */
|
|
35
|
+
readonly offsetX: number;
|
|
36
|
+
readonly offsetY: number;
|
|
37
|
+
/** The untrimmed frame's size: what an anchor is a fraction of. */
|
|
38
|
+
readonly sourceWidth: number;
|
|
39
|
+
readonly sourceHeight: number;
|
|
40
|
+
/** How long the frame shows in an animation, in seconds (Aseprite writes ms). */
|
|
41
|
+
readonly duration: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** A named run of frames: one animation, such as "slime/move". */
|
|
45
|
+
export interface SpriteTag {
|
|
46
|
+
readonly name: string;
|
|
47
|
+
/** First and last frame index, both included. */
|
|
48
|
+
readonly from: number;
|
|
49
|
+
readonly to: number;
|
|
50
|
+
readonly direction: SpriteTagDirection;
|
|
51
|
+
/** One whole loop, in seconds. A pingpong loop plays the middle frames twice. */
|
|
52
|
+
readonly duration: number;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** A parsed sheet: its frames in order, a lookup by frame name, and its tags by name. */
|
|
56
|
+
export interface SpriteSheetData {
|
|
57
|
+
/** `meta.image` as written: the image's path relative to the JSON. */
|
|
58
|
+
readonly image: string;
|
|
59
|
+
/** The image's size in pixels (`meta.size`). */
|
|
60
|
+
readonly width: number;
|
|
61
|
+
readonly height: number;
|
|
62
|
+
readonly frames: readonly SpriteFrame[];
|
|
63
|
+
/** Frame index by name. If two frames share a name, the first wins. */
|
|
64
|
+
readonly frameByName: ReadonlyMap<string, number>;
|
|
65
|
+
readonly tags: ReadonlyMap<string, SpriteTag>;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const Count = Schema.Int;
|
|
69
|
+
const Rect = Schema.Struct({ x: Count, y: Count, w: Count, h: Count });
|
|
70
|
+
const Size = Schema.Struct({ w: Count, h: Count });
|
|
71
|
+
const AsepriteFrame = Schema.Struct({
|
|
72
|
+
filename: Schema.optional(Schema.String),
|
|
73
|
+
frame: Rect,
|
|
74
|
+
rotated: Schema.optional(Schema.Boolean),
|
|
75
|
+
spriteSourceSize: Schema.optional(Rect),
|
|
76
|
+
sourceSize: Schema.optional(Size),
|
|
77
|
+
duration: Schema.optional(Schema.Finite),
|
|
78
|
+
});
|
|
79
|
+
type AsepriteFrame = typeof AsepriteFrame.Type;
|
|
80
|
+
const AsepriteSheet = Schema.Struct({
|
|
81
|
+
frames: Schema.Union([
|
|
82
|
+
Schema.Array(AsepriteFrame),
|
|
83
|
+
Schema.Record(Schema.String, AsepriteFrame),
|
|
84
|
+
]),
|
|
85
|
+
meta: Schema.Struct({
|
|
86
|
+
image: Schema.String,
|
|
87
|
+
size: Size,
|
|
88
|
+
frameTags: Schema.optional(
|
|
89
|
+
Schema.Array(
|
|
90
|
+
Schema.Struct({
|
|
91
|
+
name: Schema.String,
|
|
92
|
+
from: Count,
|
|
93
|
+
to: Count,
|
|
94
|
+
direction: Schema.optional(
|
|
95
|
+
Schema.Literals([
|
|
96
|
+
"forward",
|
|
97
|
+
"reverse",
|
|
98
|
+
"pingpong",
|
|
99
|
+
"pingpong_reverse",
|
|
100
|
+
])
|
|
101
|
+
),
|
|
102
|
+
})
|
|
103
|
+
)
|
|
104
|
+
),
|
|
105
|
+
}),
|
|
106
|
+
});
|
|
107
|
+
const decodeAseprite = Schema.decodeUnknownResult(AsepriteSheet);
|
|
108
|
+
|
|
109
|
+
/** Aseprite's default frame duration, in ms, for exports that leave it out. */
|
|
110
|
+
const DEFAULT_DURATION_MS = 100;
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Parses an Aseprite JSON export (frames as an array or a hash) and checks it: every frame
|
|
114
|
+
* inside the image, every tag inside the frames, no two tags with one name. `file` names the
|
|
115
|
+
* JSON in the error. Rotated frames (a TexturePacker option) aren't supported. A tag's
|
|
116
|
+
* `repeat` is ignored: animations loop.
|
|
117
|
+
*/
|
|
118
|
+
export const decodeSpriteSheet = (
|
|
119
|
+
file: string,
|
|
120
|
+
json: unknown
|
|
121
|
+
): Effect.Effect<SpriteSheetData, SpriteSheetError> => {
|
|
122
|
+
const fail = (message: string) =>
|
|
123
|
+
Effect.fail(
|
|
124
|
+
new SpriteSheetError({
|
|
125
|
+
file,
|
|
126
|
+
message: `The sprite sheet ${file} is invalid: ${message}`,
|
|
127
|
+
})
|
|
128
|
+
);
|
|
129
|
+
const decoded = decodeAseprite(json);
|
|
130
|
+
if (Result.isFailure(decoded)) {
|
|
131
|
+
return fail(decoded.failure.message);
|
|
132
|
+
}
|
|
133
|
+
const { meta } = decoded.success;
|
|
134
|
+
if (meta.size.w < 1 || meta.size.h < 1) {
|
|
135
|
+
return fail(`meta.size is ${meta.size.w}x${meta.size.h}`);
|
|
136
|
+
}
|
|
137
|
+
const entries: (readonly [string, AsepriteFrame])[] = Array.isArray(
|
|
138
|
+
decoded.success.frames
|
|
139
|
+
)
|
|
140
|
+
? decoded.success.frames.map(
|
|
141
|
+
(frame, index) => [frame.filename ?? String(index), frame] as const
|
|
142
|
+
)
|
|
143
|
+
: Object.entries(decoded.success.frames);
|
|
144
|
+
if (entries.length === 0) {
|
|
145
|
+
return fail("it has no frames");
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const frames: SpriteFrame[] = [];
|
|
149
|
+
const frameByName = new Map<string, number>();
|
|
150
|
+
for (const [name, frame] of entries) {
|
|
151
|
+
const problem = frameProblem(frame, meta.size);
|
|
152
|
+
if (problem !== undefined) {
|
|
153
|
+
return fail(`frame ${frames.length} (${name}) ${problem}`);
|
|
154
|
+
}
|
|
155
|
+
const { x, y, w, h } = frame.frame;
|
|
156
|
+
const placed = frame.spriteSourceSize ?? { x: 0, y: 0, w, h };
|
|
157
|
+
const source = frame.sourceSize ?? { w, h };
|
|
158
|
+
if (!frameByName.has(name)) {
|
|
159
|
+
frameByName.set(name, frames.length);
|
|
160
|
+
}
|
|
161
|
+
frames.push({
|
|
162
|
+
name,
|
|
163
|
+
x,
|
|
164
|
+
y,
|
|
165
|
+
width: w,
|
|
166
|
+
height: h,
|
|
167
|
+
offsetX: placed.x,
|
|
168
|
+
offsetY: placed.y,
|
|
169
|
+
sourceWidth: source.w,
|
|
170
|
+
sourceHeight: source.h,
|
|
171
|
+
duration: (frame.duration ?? DEFAULT_DURATION_MS) / 1000,
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const tags = readTags(meta.frameTags ?? [], frames);
|
|
176
|
+
if (typeof tags === "string") {
|
|
177
|
+
return fail(tags);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return Effect.succeed({
|
|
181
|
+
image: meta.image,
|
|
182
|
+
width: meta.size.w,
|
|
183
|
+
height: meta.size.h,
|
|
184
|
+
frames,
|
|
185
|
+
frameByName,
|
|
186
|
+
tags,
|
|
187
|
+
});
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
/** The tags by name, or what's wrong with them. */
|
|
191
|
+
const readTags = (
|
|
192
|
+
frameTags: readonly {
|
|
193
|
+
readonly name: string;
|
|
194
|
+
readonly from: number;
|
|
195
|
+
readonly to: number;
|
|
196
|
+
readonly direction?: SpriteTagDirection | undefined;
|
|
197
|
+
}[],
|
|
198
|
+
frames: readonly SpriteFrame[]
|
|
199
|
+
): Map<string, SpriteTag> | string => {
|
|
200
|
+
const tags = new Map<string, SpriteTag>();
|
|
201
|
+
for (const tag of frameTags) {
|
|
202
|
+
if (tags.has(tag.name)) {
|
|
203
|
+
return `two tags are named "${tag.name}"`;
|
|
204
|
+
}
|
|
205
|
+
if (tag.from < 0 || tag.from > tag.to || tag.to >= frames.length) {
|
|
206
|
+
return `tag "${tag.name}" runs from frame ${tag.from} to ${tag.to}, but the sheet has frames 0 to ${frames.length - 1}`;
|
|
207
|
+
}
|
|
208
|
+
const shape = {
|
|
209
|
+
from: tag.from,
|
|
210
|
+
to: tag.to,
|
|
211
|
+
direction: tag.direction ?? "forward",
|
|
212
|
+
};
|
|
213
|
+
let duration = 0;
|
|
214
|
+
for (let step = 0; step < loopLength(shape); step += 1) {
|
|
215
|
+
duration += frames[frameOfStep(shape, step)]?.duration ?? 0;
|
|
216
|
+
}
|
|
217
|
+
tags.set(tag.name, { name: tag.name, ...shape, duration });
|
|
218
|
+
}
|
|
219
|
+
return tags;
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
/** What's wrong with one frame, or undefined. */
|
|
223
|
+
const frameProblem = (
|
|
224
|
+
frame: AsepriteFrame,
|
|
225
|
+
image: { readonly w: number; readonly h: number }
|
|
226
|
+
): string | undefined => {
|
|
227
|
+
const { x, y, w, h } = frame.frame;
|
|
228
|
+
if (frame.rotated === true) {
|
|
229
|
+
return "is rotated, which isn't supported";
|
|
230
|
+
}
|
|
231
|
+
if (w < 1 || h < 1) {
|
|
232
|
+
return `is ${w}x${h}`;
|
|
233
|
+
}
|
|
234
|
+
if (x < 0 || y < 0 || x + w > image.w || y + h > image.h) {
|
|
235
|
+
return `at ${x},${y} size ${w}x${h} lies outside the ${image.w}x${image.h} image`;
|
|
236
|
+
}
|
|
237
|
+
if (frame.duration !== undefined && frame.duration <= 0) {
|
|
238
|
+
return `lasts ${frame.duration} ms`;
|
|
239
|
+
}
|
|
240
|
+
const placed = frame.spriteSourceSize;
|
|
241
|
+
const source = frame.sourceSize ?? { w, h };
|
|
242
|
+
if (
|
|
243
|
+
placed !== undefined &&
|
|
244
|
+
(placed.x < 0 ||
|
|
245
|
+
placed.y < 0 ||
|
|
246
|
+
placed.x + w > source.w ||
|
|
247
|
+
placed.y + h > source.h)
|
|
248
|
+
) {
|
|
249
|
+
return `is placed at ${placed.x},${placed.y} outside its ${source.w}x${source.h} source size`;
|
|
250
|
+
}
|
|
251
|
+
return undefined;
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
type TagShape = Pick<SpriteTag, "from" | "to" | "direction">;
|
|
255
|
+
|
|
256
|
+
const isPingpong = (tag: TagShape) =>
|
|
257
|
+
tag.direction === "pingpong" || tag.direction === "pingpong_reverse";
|
|
258
|
+
|
|
259
|
+
/** Frames in one loop: a pingpong over 0..3 plays 0 1 2 3 2 1. */
|
|
260
|
+
const loopLength = (tag: TagShape): number => {
|
|
261
|
+
const count = tag.to - tag.from + 1;
|
|
262
|
+
return isPingpong(tag) && count > 1 ? 2 * count - 2 : count;
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
/** The frame index shown at `step` of a loop. */
|
|
266
|
+
const frameOfStep = (tag: TagShape, step: number): number => {
|
|
267
|
+
const count = tag.to - tag.from + 1;
|
|
268
|
+
const back = step - count + 1;
|
|
269
|
+
switch (tag.direction) {
|
|
270
|
+
case "forward": {
|
|
271
|
+
return tag.from + step;
|
|
272
|
+
}
|
|
273
|
+
case "reverse": {
|
|
274
|
+
return tag.to - step;
|
|
275
|
+
}
|
|
276
|
+
case "pingpong": {
|
|
277
|
+
return step < count ? tag.from + step : tag.to - back;
|
|
278
|
+
}
|
|
279
|
+
case "pingpong_reverse": {
|
|
280
|
+
return step < count ? tag.to - step : tag.from + back;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* The frame index (into `sheet.frames`) a tag shows `seconds` into its animation, looping,
|
|
287
|
+
* from each frame's duration and the tag's direction. Allocates nothing: call it per sprite
|
|
288
|
+
* per frame. Negative times count back from the end of a loop.
|
|
289
|
+
*/
|
|
290
|
+
export const frameAt = (
|
|
291
|
+
sheet: SpriteSheetData,
|
|
292
|
+
tag: SpriteTag,
|
|
293
|
+
seconds: number
|
|
294
|
+
): number => {
|
|
295
|
+
if (tag.from === tag.to || !(tag.duration > 0)) {
|
|
296
|
+
return tag.from;
|
|
297
|
+
}
|
|
298
|
+
let left = seconds % tag.duration;
|
|
299
|
+
if (left < 0) {
|
|
300
|
+
left += tag.duration;
|
|
301
|
+
}
|
|
302
|
+
const steps = loopLength(tag);
|
|
303
|
+
for (let step = 0; step < steps; step += 1) {
|
|
304
|
+
const index = frameOfStep(tag, step);
|
|
305
|
+
left -= sheet.frames[index]?.duration ?? 0;
|
|
306
|
+
if (left < 0) {
|
|
307
|
+
return index;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
// Rounding can leave a hair of time past the last step.
|
|
311
|
+
return frameOfStep(tag, steps - 1);
|
|
312
|
+
};
|
package/src/stage.ts
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import * as THREE from "three";
|
|
2
|
+
|
|
3
|
+
import { PixelCamera } from "./pixel-camera";
|
|
4
|
+
|
|
5
|
+
/** Which camera a Stage builds. */
|
|
6
|
+
export type StageCameraKind = "perspective" | "orthographic" | "pixel";
|
|
7
|
+
|
|
8
|
+
/** The camera type for a kind: what `Stage.camera` is. */
|
|
9
|
+
export type StageCamera<K extends StageCameraKind> = K extends "pixel"
|
|
10
|
+
? PixelCamera
|
|
11
|
+
: K extends "orthographic"
|
|
12
|
+
? THREE.OrthographicCamera
|
|
13
|
+
: THREE.PerspectiveCamera;
|
|
14
|
+
|
|
15
|
+
export interface StageOptions<K extends StageCameraKind = "perspective"> {
|
|
16
|
+
/**
|
|
17
|
+
* "perspective" (the default) for a 3D world seen from a point. "orthographic" for a flat
|
|
18
|
+
* board, where sizes shouldn't shrink with distance; place it with `fitCamera`. "pixel" for
|
|
19
|
+
* pixel art seen straight down (a `PixelCamera`); the stage keeps it told the drawing buffer's size.
|
|
20
|
+
*/
|
|
21
|
+
readonly camera?: K;
|
|
22
|
+
/**
|
|
23
|
+
* Height of an orthographic camera's view, in world units, until `fitCamera` changes it.
|
|
24
|
+
* Defaults to 10. For a pixel camera, the height it aims for (defaults to 20).
|
|
25
|
+
*/
|
|
26
|
+
readonly viewHeight?: number;
|
|
27
|
+
/** Pixel camera only: sprite pixels per world unit. Defaults to 16. */
|
|
28
|
+
readonly pixelsPerUnit?: number;
|
|
29
|
+
readonly background?: THREE.ColorRepresentation;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** A perspective camera's field of view, in degrees. Set `stage.camera.fov` to change it. */
|
|
33
|
+
const FOV = 50;
|
|
34
|
+
/** The near and far planes of a perspective or orthographic camera. */
|
|
35
|
+
const NEAR = 0.1;
|
|
36
|
+
const FAR = 400;
|
|
37
|
+
/**
|
|
38
|
+
* Caps devicePixelRatio for a perspective or orthographic stage; 2 keeps phones sharp without
|
|
39
|
+
* rendering 9x the pixels. A pixel stage isn't capped: see `drawingBuffer`.
|
|
40
|
+
*/
|
|
41
|
+
const MAX_PIXEL_RATIO = 2;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* A renderer, scene and camera that fill a container element and resize with it.
|
|
45
|
+
* The camera is perspective unless `camera: "orthographic"` or `"pixel"` is passed; on resize, an
|
|
46
|
+
* orthographic camera keeps its view height and widens or narrows to the new aspect ratio, and a
|
|
47
|
+
* pixel camera picks its pixel scale again. The pixel ratio is read again on every resize, and
|
|
48
|
+
* a pixel stage sizes its drawing buffer to the canvas's exact device pixels, where the others
|
|
49
|
+
* cap the ratio at 2 (see `drawingBuffer`). The camera is public: change its fields (a
|
|
50
|
+
* perspective camera's `fov`, `near` and `far`) and call `updateProjectionMatrix()`. So is the
|
|
51
|
+
* scene: set `stage.scene.fog` for fog.
|
|
52
|
+
* `dispose()` frees every GPU resource in the scene, except kept textures (see `keepTexture`),
|
|
53
|
+
* and removes the canvas.
|
|
54
|
+
*/
|
|
55
|
+
export class Stage<K extends StageCameraKind = "perspective"> {
|
|
56
|
+
readonly renderer: THREE.WebGLRenderer;
|
|
57
|
+
readonly scene: THREE.Scene = new THREE.Scene();
|
|
58
|
+
readonly camera: StageCamera<K>;
|
|
59
|
+
private readonly container: HTMLElement;
|
|
60
|
+
private readonly resizeObserver: ResizeObserver;
|
|
61
|
+
|
|
62
|
+
constructor(container: HTMLElement, options: StageOptions<K> = {}) {
|
|
63
|
+
this.container = container;
|
|
64
|
+
const pixel = options.camera === "pixel";
|
|
65
|
+
this.renderer = new THREE.WebGLRenderer({
|
|
66
|
+
// A pixel camera's edges are already on pixels, so it needs no multisampling.
|
|
67
|
+
antialias: !pixel,
|
|
68
|
+
powerPreference: "high-performance",
|
|
69
|
+
});
|
|
70
|
+
const canvas = this.renderer.domElement;
|
|
71
|
+
canvas.style.display = "block";
|
|
72
|
+
canvas.style.width = "100%";
|
|
73
|
+
canvas.style.height = "100%";
|
|
74
|
+
if (pixel) {
|
|
75
|
+
canvas.style.imageRendering = "pixelated";
|
|
76
|
+
}
|
|
77
|
+
container.append(canvas);
|
|
78
|
+
|
|
79
|
+
if (pixel) {
|
|
80
|
+
this.camera = new PixelCamera({
|
|
81
|
+
...(options.pixelsPerUnit === undefined
|
|
82
|
+
? {}
|
|
83
|
+
: { pixelsPerUnit: options.pixelsPerUnit }),
|
|
84
|
+
...(options.viewHeight === undefined
|
|
85
|
+
? {}
|
|
86
|
+
: { viewHeight: options.viewHeight }),
|
|
87
|
+
}) as StageCamera<K>;
|
|
88
|
+
} else if (options.camera === "orthographic") {
|
|
89
|
+
const half = (options.viewHeight ?? 10) / 2;
|
|
90
|
+
this.camera = new THREE.OrthographicCamera(
|
|
91
|
+
-half,
|
|
92
|
+
half,
|
|
93
|
+
half,
|
|
94
|
+
-half,
|
|
95
|
+
NEAR,
|
|
96
|
+
FAR
|
|
97
|
+
) as StageCamera<K>;
|
|
98
|
+
} else {
|
|
99
|
+
this.camera = new THREE.PerspectiveCamera(
|
|
100
|
+
FOV,
|
|
101
|
+
1,
|
|
102
|
+
NEAR,
|
|
103
|
+
FAR
|
|
104
|
+
) as StageCamera<K>;
|
|
105
|
+
}
|
|
106
|
+
if (options.background !== undefined) {
|
|
107
|
+
this.scene.background = new THREE.Color(options.background);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
this.resizeObserver = new ResizeObserver((entries) =>
|
|
111
|
+
this.resize(entries.at(-1))
|
|
112
|
+
);
|
|
113
|
+
try {
|
|
114
|
+
// Also fires when only the pixel ratio changes (the window moved to another screen).
|
|
115
|
+
this.resizeObserver.observe(container, {
|
|
116
|
+
box: "device-pixel-content-box",
|
|
117
|
+
});
|
|
118
|
+
} catch {
|
|
119
|
+
this.resizeObserver.observe(container);
|
|
120
|
+
}
|
|
121
|
+
this.resize(undefined);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
render(): void {
|
|
125
|
+
this.renderer.render(this.scene, this.camera);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
dispose(): void {
|
|
129
|
+
this.resizeObserver.disconnect();
|
|
130
|
+
disposeObject(this.scene);
|
|
131
|
+
this.renderer.dispose();
|
|
132
|
+
this.renderer.domElement.remove();
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
private resize(entry: ResizeObserverEntry | undefined) {
|
|
136
|
+
const width = Math.max(1, this.container.clientWidth);
|
|
137
|
+
const height = Math.max(1, this.container.clientHeight);
|
|
138
|
+
const camera = this.camera as
|
|
139
|
+
| THREE.PerspectiveCamera
|
|
140
|
+
| THREE.OrthographicCamera;
|
|
141
|
+
const target = drawingBuffer(
|
|
142
|
+
camera instanceof PixelCamera,
|
|
143
|
+
width,
|
|
144
|
+
height,
|
|
145
|
+
window.devicePixelRatio,
|
|
146
|
+
MAX_PIXEL_RATIO,
|
|
147
|
+
entry?.devicePixelContentBoxSize?.[0]
|
|
148
|
+
);
|
|
149
|
+
this.renderer.setPixelRatio(target.pixelRatio);
|
|
150
|
+
this.renderer.setSize(target.width, target.height, false);
|
|
151
|
+
const aspect = width / height;
|
|
152
|
+
if (camera instanceof PixelCamera) {
|
|
153
|
+
const buffer = this.renderer.getDrawingBufferSize(BUFFER_SIZE);
|
|
154
|
+
camera.setBufferSize(buffer.x, buffer.y);
|
|
155
|
+
} else if (camera instanceof THREE.OrthographicCamera) {
|
|
156
|
+
const centerX = (camera.left + camera.right) / 2;
|
|
157
|
+
const halfWidth = ((camera.top - camera.bottom) / 2) * aspect;
|
|
158
|
+
camera.left = centerX - halfWidth;
|
|
159
|
+
camera.right = centerX + halfWidth;
|
|
160
|
+
} else {
|
|
161
|
+
camera.aspect = aspect;
|
|
162
|
+
}
|
|
163
|
+
camera.updateProjectionMatrix();
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const BUFFER_SIZE = new THREE.Vector2();
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* The drawing buffer for a canvas `width` x `height` CSS pixels: the pixel ratio and size to
|
|
171
|
+
* give `renderer.setSize`. A pixel stage never caps the ratio, since a smaller buffer is
|
|
172
|
+
* stretched onto the screen by a fraction and sprite pixels would cover uneven numbers of
|
|
173
|
+
* screen pixels; where the browser reports the canvas's device pixels (`device`), it uses
|
|
174
|
+
* those, as width * ratio can round a pixel off from the browser's size at a fractional ratio.
|
|
175
|
+
*/
|
|
176
|
+
export const drawingBuffer = (
|
|
177
|
+
pixel: boolean,
|
|
178
|
+
width: number,
|
|
179
|
+
height: number,
|
|
180
|
+
devicePixelRatio: number,
|
|
181
|
+
maxPixelRatio: number,
|
|
182
|
+
device?: { readonly inlineSize: number; readonly blockSize: number }
|
|
183
|
+
): { pixelRatio: number; width: number; height: number } => {
|
|
184
|
+
if (!pixel) {
|
|
185
|
+
return {
|
|
186
|
+
pixelRatio: Math.min(devicePixelRatio, maxPixelRatio),
|
|
187
|
+
width,
|
|
188
|
+
height,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
if (device === undefined) {
|
|
192
|
+
return { pixelRatio: devicePixelRatio, width, height };
|
|
193
|
+
}
|
|
194
|
+
return {
|
|
195
|
+
pixelRatio: 1,
|
|
196
|
+
width: Math.max(1, device.inlineSize),
|
|
197
|
+
height: Math.max(1, device.blockSize),
|
|
198
|
+
};
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
const KEEP = "arcadekitKeep";
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Marks a texture that something outside the scene owns, such as a sprite sheet the app
|
|
205
|
+
* loaded: `disposeObject` and `Stage.dispose` leave it alone, and its owner disposes it.
|
|
206
|
+
* Returns the texture.
|
|
207
|
+
*/
|
|
208
|
+
export const keepTexture = <T extends THREE.Texture>(texture: T): T => {
|
|
209
|
+
texture.userData[KEEP] = true;
|
|
210
|
+
return texture;
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
/** Whether `keepTexture` marked this texture. */
|
|
214
|
+
export const isKeptTexture = (texture: THREE.Texture): boolean =>
|
|
215
|
+
texture.userData[KEEP] === true;
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Frees the geometries, materials and textures of `root` and everything under it: the textures
|
|
219
|
+
* a material holds (its maps, and a shader material's texture uniforms) and, for a scene, its
|
|
220
|
+
* background and environment. Textures marked with `keepTexture` are skipped.
|
|
221
|
+
* `Stage.dispose` calls it on the whole scene. A view that removes an object from the
|
|
222
|
+
* scene calls it on that object, which `Stage.dispose` no longer reaches.
|
|
223
|
+
*/
|
|
224
|
+
export const disposeObject = (root: THREE.Object3D): void => {
|
|
225
|
+
const textures = new Set<THREE.Texture>();
|
|
226
|
+
if (root instanceof THREE.Scene) {
|
|
227
|
+
addTexture(textures, root.background);
|
|
228
|
+
addTexture(textures, root.environment);
|
|
229
|
+
}
|
|
230
|
+
root.traverse((object) => {
|
|
231
|
+
if (
|
|
232
|
+
object instanceof THREE.Mesh ||
|
|
233
|
+
object instanceof THREE.Line ||
|
|
234
|
+
object instanceof THREE.Points
|
|
235
|
+
) {
|
|
236
|
+
object.geometry.dispose();
|
|
237
|
+
const materials: THREE.Material[] = Array.isArray(object.material)
|
|
238
|
+
? object.material
|
|
239
|
+
: [object.material];
|
|
240
|
+
for (const material of materials) {
|
|
241
|
+
for (const value of Object.values(material)) {
|
|
242
|
+
addTexture(textures, value);
|
|
243
|
+
}
|
|
244
|
+
if (material instanceof THREE.ShaderMaterial) {
|
|
245
|
+
for (const uniform of Object.values(material.uniforms)) {
|
|
246
|
+
addTexture(textures, uniform.value);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
material.dispose();
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
});
|
|
253
|
+
for (const texture of textures) {
|
|
254
|
+
texture.dispose();
|
|
255
|
+
}
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
const addTexture = (textures: Set<THREE.Texture>, value: unknown) => {
|
|
259
|
+
if (value instanceof THREE.Texture && !isKeptTexture(value)) {
|
|
260
|
+
textures.add(value);
|
|
261
|
+
}
|
|
262
|
+
};
|