@umicat/three-sdk 0.15.0 → 0.16.1

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
@@ -9,8 +9,9 @@ is what new 3D projects start from.
9
9
 
10
10
  What it has: the scene3d format and loader, physics wiring, a character
11
11
  controller and animator, an input layer that mounts on-screen controls on touch
12
- devices and binds keys everywhere, bone sockets, hit tints, and audio. What it
13
- still does not have: an editor.
12
+ devices and binds keys everywhere, bone sockets, hit tints, audio, and the
13
+ editor's screenshot/video-capture protocol. What it still does not have: an
14
+ editor.
14
15
 
15
16
  > This paragraph said "seed, not a product — no input system, no character
16
17
  > controller, no audio, and no published package" for ten minor versions after
@@ -23,7 +24,7 @@ still does not have: an editor.
23
24
  │
24
25
  @umicat/three-sdk ThreeUmicat · scene3d · loadScene3D · physics · Input3D
25
26
  CharacterController3D · CharacterAnimator · GameAudio
26
- sockets · tints
27
+ sockets · tints · setupScreenshotListener/setupRecordingListener
27
28
  ▲
28
29
  your game gameplay
29
30
  ```
@@ -315,15 +315,32 @@ export async function loadScene3D(scene3d, manifest, opts = {}) {
315
315
  dispose() {
316
316
  for (const m of mixers)
317
317
  m.stopAllAction();
318
+ // Textures were never freed here — only geometries/materials — because
319
+ // every prior consumer (a game) loads a scene once per session. The
320
+ // Scene Previewer (platform-ui) reloads on every click-to-a-different-
321
+ // scene and every manual refresh, so the leak that used to be
322
+ // session-lifetime now actually accumulates. A Set guards against
323
+ // disposing a texture twice when two materials share one (e.g. an
324
+ // atlas'd model).
325
+ const disposedTextures = new Set();
326
+ const disposeMaterial = (mat) => {
327
+ for (const value of Object.values(mat)) {
328
+ if (value instanceof THREE.Texture && !disposedTextures.has(value)) {
329
+ disposedTextures.add(value);
330
+ value.dispose();
331
+ }
332
+ }
333
+ mat.dispose();
334
+ };
318
335
  scene.traverse((o) => {
319
336
  const mesh = o;
320
337
  if (mesh.geometry)
321
338
  mesh.geometry.dispose();
322
339
  const mat = mesh.material;
323
340
  if (Array.isArray(mat))
324
- mat.forEach((m) => m.dispose());
325
- else
326
- mat?.dispose();
341
+ mat.forEach(disposeMaterial);
342
+ else if (mat)
343
+ disposeMaterial(mat);
327
344
  });
328
345
  },
329
346
  };
@@ -0,0 +1,19 @@
1
+ import type * as THREE from 'three';
2
+ /**
3
+ * Sets up postMessage listeners for video recording of the renderer's
4
+ * canvas. Same wire protocol as `@umicat/phaser-sdk`'s
5
+ * `recording/RecordingManager.ts` — the host doesn't need to know which
6
+ * engine is running.
7
+ *
8
+ * Parent sends: 'startRecording'
9
+ * Game responds: { type: 'recordingStarted' }
10
+ *
11
+ * Parent sends: 'stopRecording'
12
+ * Game responds: { type: 'recordingComplete', data: string (base64 data URL), mimeType: string }
13
+ *
14
+ * `canvas.captureStream()` samples the compositor's output directly, so
15
+ * unlike {@link "./ScreenshotManager.js".takeScreenshot}, it works whether
16
+ * or not the renderer was constructed with `preserveDrawingBuffer` — but the
17
+ * template sets it anyway, for the screenshot path.
18
+ */
19
+ export declare function setupRecordingListener(renderer: THREE.WebGLRenderer): void;
@@ -0,0 +1,67 @@
1
+ let mediaRecorder = null;
2
+ let recordedChunks = [];
3
+ /**
4
+ * Sets up postMessage listeners for video recording of the renderer's
5
+ * canvas. Same wire protocol as `@umicat/phaser-sdk`'s
6
+ * `recording/RecordingManager.ts` — the host doesn't need to know which
7
+ * engine is running.
8
+ *
9
+ * Parent sends: 'startRecording'
10
+ * Game responds: { type: 'recordingStarted' }
11
+ *
12
+ * Parent sends: 'stopRecording'
13
+ * Game responds: { type: 'recordingComplete', data: string (base64 data URL), mimeType: string }
14
+ *
15
+ * `canvas.captureStream()` samples the compositor's output directly, so
16
+ * unlike {@link "./ScreenshotManager.js".takeScreenshot}, it works whether
17
+ * or not the renderer was constructed with `preserveDrawingBuffer` — but the
18
+ * template sets it anyway, for the screenshot path.
19
+ */
20
+ export function setupRecordingListener(renderer) {
21
+ window.addEventListener('message', (event) => {
22
+ if (event.data === 'startRecording') {
23
+ try {
24
+ const canvas = renderer.domElement;
25
+ if (!canvas)
26
+ return;
27
+ recordedChunks = [];
28
+ const stream = canvas.captureStream(30);
29
+ // Pick best available codec.
30
+ const mimeType = MediaRecorder.isTypeSupported('video/webm;codecs=vp9')
31
+ ? 'video/webm;codecs=vp9'
32
+ : 'video/webm';
33
+ mediaRecorder = new MediaRecorder(stream, {
34
+ mimeType,
35
+ videoBitsPerSecond: 1500000, // 1.5 Mbps
36
+ });
37
+ mediaRecorder.ondataavailable = (e) => {
38
+ if (e.data.size > 0)
39
+ recordedChunks.push(e.data);
40
+ };
41
+ mediaRecorder.onstop = () => {
42
+ const blob = new Blob(recordedChunks, { type: 'video/webm' });
43
+ const reader = new FileReader();
44
+ reader.onloadend = () => {
45
+ window.parent.postMessage({
46
+ type: 'recordingComplete',
47
+ data: reader.result,
48
+ mimeType: 'video/webm',
49
+ }, '*');
50
+ };
51
+ reader.readAsDataURL(blob);
52
+ };
53
+ mediaRecorder.start(1000); // collect data every 1 second
54
+ window.parent.postMessage({ type: 'recordingStarted' }, '*');
55
+ }
56
+ catch (e) {
57
+ console.error('[UmicatSDK] Recording failed to start:', e);
58
+ window.parent.postMessage({ type: 'recordingError', error: String(e) }, '*');
59
+ }
60
+ }
61
+ if (event.data === 'stopRecording') {
62
+ if (mediaRecorder && mediaRecorder.state !== 'inactive') {
63
+ mediaRecorder.stop();
64
+ }
65
+ }
66
+ });
67
+ }
@@ -0,0 +1,22 @@
1
+ import type * as THREE from 'three';
2
+ /**
3
+ * Sets up a postMessage listener that captures the renderer's canvas when the
4
+ * parent window requests a screenshot. Same wire protocol as
5
+ * `@umicat/phaser-sdk`'s `screenshot/ScreenshotManager.ts` — the host
6
+ * (umicat-home-ui) speaks one protocol to both engines and never needs to
7
+ * know which one is running.
8
+ *
9
+ * Parent sends: { type: 'screenshot' }
10
+ * Game responds: { type: 'screenshot_result', dataUrl: string }
11
+ *
12
+ * Requires the renderer to have been constructed with
13
+ * `preserveDrawingBuffer: true` — without it, `canvas.toDataURL()` on a
14
+ * WebGL context can return a blank image depending on exactly when the
15
+ * browser clears the drawing buffer after compositing. This is NOT
16
+ * something the SDK can fix after the fact (it's a renderer-construction-time
17
+ * option), so a game that wants captures to work must pass it when creating
18
+ * its `THREE.WebGLRenderer`.
19
+ */
20
+ export declare function setupScreenshotListener(renderer: THREE.WebGLRenderer): void;
21
+ /** Take a screenshot programmatically. Returns a base64 PNG data URL. */
22
+ export declare function takeScreenshot(renderer: THREE.WebGLRenderer): string | null;
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Sets up a postMessage listener that captures the renderer's canvas when the
3
+ * parent window requests a screenshot. Same wire protocol as
4
+ * `@umicat/phaser-sdk`'s `screenshot/ScreenshotManager.ts` — the host
5
+ * (umicat-home-ui) speaks one protocol to both engines and never needs to
6
+ * know which one is running.
7
+ *
8
+ * Parent sends: { type: 'screenshot' }
9
+ * Game responds: { type: 'screenshot_result', dataUrl: string }
10
+ *
11
+ * Requires the renderer to have been constructed with
12
+ * `preserveDrawingBuffer: true` — without it, `canvas.toDataURL()` on a
13
+ * WebGL context can return a blank image depending on exactly when the
14
+ * browser clears the drawing buffer after compositing. This is NOT
15
+ * something the SDK can fix after the fact (it's a renderer-construction-time
16
+ * option), so a game that wants captures to work must pass it when creating
17
+ * its `THREE.WebGLRenderer`.
18
+ */
19
+ export function setupScreenshotListener(renderer) {
20
+ window.addEventListener('message', (event) => {
21
+ if (event.data?.type === 'screenshot') {
22
+ try {
23
+ const dataUrl = renderer.domElement.toDataURL('image/png');
24
+ window.parent.postMessage({ type: 'screenshot_result', dataUrl }, '*');
25
+ }
26
+ catch (e) {
27
+ console.error('[UmicatSDK] Screenshot failed:', e);
28
+ }
29
+ }
30
+ else if (event.data?.type === 'screenshotRegion') {
31
+ // Marquee-to-chat: the host drew a selection rect over the iframe (in
32
+ // iframe-relative CSS px); crop the canvas to it here, where the
33
+ // canvas's on-screen position + intrinsic resolution are known
34
+ // (handles devicePixelRatio scaling — a 3D game always runs 'resize'
35
+ // scale mode, never letterboxed, but pixel ratio still differs from
36
+ // CSS size).
37
+ try {
38
+ const r = event.data.rect;
39
+ const dataUrl = cropCanvasRegion(renderer.domElement, r);
40
+ window.parent.postMessage({ type: 'screenshot_region_result', dataUrl, requestId: event.data.requestId }, '*');
41
+ }
42
+ catch (e) {
43
+ console.error('[UmicatSDK] Region screenshot failed:', e);
44
+ window.parent.postMessage({ type: 'screenshot_region_result', dataUrl: null, requestId: event.data?.requestId }, '*');
45
+ }
46
+ }
47
+ });
48
+ }
49
+ /**
50
+ * Crop the canvas to a rect given in iframe-relative CSS pixels (the host's
51
+ * marquee). Maps display px → intrinsic canvas px via the canvas's bounding
52
+ * rect + resolution, clamps to bounds. Same math as phaser-sdk's
53
+ * `cropCanvasRegion` — deliberately engine-agnostic, it only looks at the
54
+ * canvas element.
55
+ */
56
+ function cropCanvasRegion(canvas, rect) {
57
+ const cr = canvas.getBoundingClientRect();
58
+ if (cr.width <= 0 || cr.height <= 0)
59
+ return null;
60
+ const scale = canvas.width / cr.width; // intrinsic px per displayed px
61
+ let sx = Math.round((rect.x - cr.x) * scale);
62
+ let sy = Math.round((rect.y - cr.y) * scale);
63
+ let sw = Math.round(rect.width * scale);
64
+ let sh = Math.round(rect.height * scale);
65
+ // Clamp to the canvas (the marquee can run past the edges).
66
+ sx = Math.max(0, Math.min(sx, canvas.width - 1));
67
+ sy = Math.max(0, Math.min(sy, canvas.height - 1));
68
+ sw = Math.max(1, Math.min(sw, canvas.width - sx));
69
+ sh = Math.max(1, Math.min(sh, canvas.height - sy));
70
+ const out = document.createElement('canvas');
71
+ out.width = sw;
72
+ out.height = sh;
73
+ const ctx = out.getContext('2d');
74
+ if (!ctx)
75
+ return null;
76
+ ctx.drawImage(canvas, sx, sy, sw, sh, 0, 0, sw, sh);
77
+ return out.toDataURL('image/png');
78
+ }
79
+ /** Take a screenshot programmatically. Returns a base64 PNG data URL. */
80
+ export function takeScreenshot(renderer) {
81
+ try {
82
+ return renderer.domElement.toDataURL('image/png');
83
+ }
84
+ catch (e) {
85
+ console.error('[UmicatSDK] Screenshot failed:', e);
86
+ return null;
87
+ }
88
+ }
package/dist/index.d.ts CHANGED
@@ -12,6 +12,8 @@ export { attachToSocket, findBone, boneNames } from './Sockets.js';
12
12
  export { flashTint, updateTints, isTinted } from './Tint.js';
13
13
  export { GameAudio } from './GameAudio.js';
14
14
  export type { GameAudioOptions, AudioClipSpec } from './GameAudio.js';
15
+ export { setupScreenshotListener, takeScreenshot } from './capture/ScreenshotManager.js';
16
+ export { setupRecordingListener } from './capture/RecordingManager.js';
15
17
  export type { Attachment } from './Sockets.js';
16
18
  export type { LoadedScene3D, LoadSceneOptions } from './SceneLoader3D.js';
17
19
  export { ORIENTATION_DIMENSIONS } from '@umicat/platform-sdk/orientation.js';
package/dist/index.js CHANGED
@@ -12,6 +12,8 @@ export { Input3D } from './Input3D.js';
12
12
  export { attachToSocket, findBone, boneNames } from './Sockets.js';
13
13
  export { flashTint, updateTints, isTinted } from './Tint.js';
14
14
  export { GameAudio } from './GameAudio.js';
15
+ export { setupScreenshotListener, takeScreenshot } from './capture/ScreenshotManager.js';
16
+ export { setupRecordingListener } from './capture/RecordingManager.js';
15
17
  // Re-exported so a game imports one package for the common case. A game should
16
18
  // not have to know that identity and saves come from a different package than
17
19
  // the renderer.
package/dist/scene3d.d.ts CHANGED
@@ -176,6 +176,14 @@ export interface Manifest3D {
176
176
  /** Identity quaternion, spelled out so callers don't have to remember the order. */
177
177
  export declare const IDENTITY_QUAT: Quat;
178
178
  export declare function vec3(x?: number, y?: number, z?: number): Vec3;
179
+ /**
180
+ * Wrap a single model as a `Manifest3D` so `loadModelAsset` can load it
181
+ * without a caller hand-rolling the boilerplate every time. `scenes`/
182
+ * `initialScene` are unused by `loadModelAsset` (only `loadScene3D` reads
183
+ * them) — left empty rather than optional so the type stays exactly
184
+ * `Manifest3D`, not a new shape callers have to learn.
185
+ */
186
+ export declare function singleModelManifest(model: ModelAsset3D): Manifest3D;
179
187
  /**
180
188
  * Validate a scene enough to fail loudly at author time rather than as a blank
181
189
  * screen at play time. Returns the problems; empty means usable.
package/dist/scene3d.js CHANGED
@@ -26,6 +26,16 @@
26
26
  /** Identity quaternion, spelled out so callers don't have to remember the order. */
27
27
  export const IDENTITY_QUAT = [0, 0, 0, 1];
28
28
  export function vec3(x = 0, y = 0, z = 0) { return { x, y, z }; }
29
+ /**
30
+ * Wrap a single model as a `Manifest3D` so `loadModelAsset` can load it
31
+ * without a caller hand-rolling the boilerplate every time. `scenes`/
32
+ * `initialScene` are unused by `loadModelAsset` (only `loadScene3D` reads
33
+ * them) — left empty rather than optional so the type stays exactly
34
+ * `Manifest3D`, not a new shape callers have to learn.
35
+ */
36
+ export function singleModelManifest(model) {
37
+ return { schemaVersion: 1, initialScene: '', scenes: [], models: [model] };
38
+ }
29
39
  /**
30
40
  * Validate a scene enough to fail loudly at author time rather than as a blank
31
41
  * screen at play time. Returns the problems; empty means usable.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@umicat/three-sdk",
3
- "version": "0.15.0",
3
+ "version": "0.16.1",
4
4
  "description": "Three.js runtime for Umicat games: the scene3d design format, its loader with physics, a kinematic character controller, and the Umicat platform via @umicat/platform-sdk.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",