@caperjs/plugin-3d 0.0.0-stage → 0.8.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 +190 -2
- package/lib/Entity3D.d.ts +28 -0
- package/lib/Entity3D.d.ts.map +1 -0
- package/lib/Model.d.ts +13 -0
- package/lib/Model.d.ts.map +1 -0
- package/lib/Plugin3D.d.ts +20 -0
- package/lib/Plugin3D.d.ts.map +1 -0
- package/lib/View3D.d.ts +58 -0
- package/lib/View3D.d.ts.map +1 -0
- package/lib/caper-plugin-3d.mjs +396 -0
- package/lib/caper-plugin-3d.mjs.map +1 -0
- package/lib/factory3d.d.ts +34 -0
- package/lib/factory3d.d.ts.map +1 -0
- package/lib/fit.d.ts +7 -0
- package/lib/fit.d.ts.map +1 -0
- package/lib/index.d.ts +14 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/owned.d.ts +22 -0
- package/lib/owned.d.ts.map +1 -0
- package/lib/props.d.ts +60 -0
- package/lib/props.d.ts.map +1 -0
- package/lib/quality.d.ts +9 -0
- package/lib/quality.d.ts.map +1 -0
- package/lib/renderer.d.ts +2 -0
- package/lib/renderer.d.ts.map +1 -0
- package/lib/version.d.ts +2 -0
- package/lib/version.d.ts.map +1 -0
- package/package.json +46 -3
- package/src/Entity3D.ts +72 -0
- package/src/Model.ts +73 -0
- package/src/Plugin3D.ts +35 -0
- package/src/View3D.ts +173 -0
- package/src/factory3d.ts +188 -0
- package/src/fit.ts +10 -0
- package/src/index.ts +13 -0
- package/src/owned.ts +42 -0
- package/src/props.ts +96 -0
- package/src/quality.ts +12 -0
- package/src/renderer.ts +22 -0
- package/src/version.ts +1 -0
package/lib/quality.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export type Quality3D = 'auto' | 'low' | 'high';
|
|
2
|
+
export declare function resolveQuality(quality: Quality3D, device: {
|
|
3
|
+
isMobile: boolean;
|
|
4
|
+
resolution: number;
|
|
5
|
+
}): {
|
|
6
|
+
shadowQuality: 'low' | 'medium' | 'high';
|
|
7
|
+
resolution: number | 'inherit';
|
|
8
|
+
};
|
|
9
|
+
//# sourceMappingURL=quality.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"quality.d.ts","sourceRoot":"","sources":["../src/quality.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,SAAS,GAAG,MAAM,GAAG,KAAK,GAAG,MAAM,CAAC;AAEhD,wBAAgB,cAAc,CAC5B,OAAO,EAAE,SAAS,EAClB,MAAM,EAAE;IAAE,QAAQ,EAAE,OAAO,CAAC;IAAC,UAAU,EAAE,MAAM,CAAA;CAAE,GAChD;IAAE,aAAa,EAAE,KAAK,GAAG,QAAQ,GAAG,MAAM,CAAC;IAAC,UAAU,EAAE,MAAM,GAAG,SAAS,CAAA;CAAE,CAM9E"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"renderer.d.ts","sourceRoot":"","sources":["../src/renderer.ts"],"names":[],"mappings":"AAOA,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,OAAO,GAAG,IAAI,CAcrD"}
|
package/lib/version.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"version.d.ts","sourceRoot":"","sources":["../src/version.ts"],"names":[],"mappings":"AAAA,eAAO,MAAM,OAAO,UAAU,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,49 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@caperjs/plugin-3d",
|
|
3
|
-
"
|
|
4
|
-
"
|
|
5
|
-
|
|
3
|
+
"description": "Experimental 3D for Caper, built on @pixi/3d",
|
|
4
|
+
"keywords": [
|
|
5
|
+
"3d",
|
|
6
|
+
"gltf",
|
|
7
|
+
"@pixi/3d",
|
|
8
|
+
"@caperjs/core",
|
|
9
|
+
"caper"
|
|
10
|
+
],
|
|
11
|
+
"version": "0.8.0",
|
|
12
|
+
"files": [
|
|
13
|
+
"lib",
|
|
14
|
+
"src",
|
|
15
|
+
"!src/**/*.test.ts"
|
|
16
|
+
],
|
|
17
|
+
"main": "lib/caper-plugin-3d.mjs",
|
|
18
|
+
"types": "lib/index.d.ts",
|
|
19
|
+
"exports": {
|
|
20
|
+
".": {
|
|
21
|
+
"default": "./lib/caper-plugin-3d.mjs",
|
|
22
|
+
"types": "./lib/index.d.ts"
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
"sideEffects": false,
|
|
26
|
+
"caper": {
|
|
27
|
+
"phase": "pre-renderer"
|
|
28
|
+
},
|
|
29
|
+
"scripts": {
|
|
30
|
+
"build": "pnpm run generate-version && vite build",
|
|
31
|
+
"test": "vitest run",
|
|
32
|
+
"generate-version": "node generateVersion.mjs"
|
|
33
|
+
},
|
|
34
|
+
"author": "",
|
|
35
|
+
"license": "MIT",
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"@caperjs/core": "^0.8.0",
|
|
38
|
+
"@pixi/3d": "0.1.0",
|
|
39
|
+
"happy-dom": "^15",
|
|
40
|
+
"vite": "^8.0.7",
|
|
41
|
+
"vite-plugin-dts": "^4.5.3",
|
|
42
|
+
"vitest": "^3"
|
|
43
|
+
},
|
|
44
|
+
"peerDependencies": {
|
|
45
|
+
"@caperjs/core": "^0.8.0",
|
|
46
|
+
"@pixi/3d": "0.1.0",
|
|
47
|
+
"pixi.js": "8.23.0"
|
|
48
|
+
}
|
|
6
49
|
}
|
package/src/Entity3D.ts
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { Container3D } from '@pixi/3d';
|
|
2
|
+
import { bindAllMethods, WithLifecycle, WithSignals } from '@caperjs/core';
|
|
3
|
+
import { createFactory3D } from './factory3d';
|
|
4
|
+
import type { Factory3D } from './factory3d';
|
|
5
|
+
import { ownedByView } from './owned';
|
|
6
|
+
import type { Owned3D } from './owned';
|
|
7
|
+
import type { View3D } from './View3D';
|
|
8
|
+
|
|
9
|
+
type Listen<Self> = (event: string | symbol, fn: (...args: any[]) => void, context?: any) => Self;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Base class for 3D entities: a `Container3D` with the Caper lifecycle (`added` / `removed` / `update`),
|
|
13
|
+
* signals, and its own 3D factory. Declare one like any entity (`defineEntity` + default export under
|
|
14
|
+
* `src/entities/`) and build it with `view.add.entity(id, props)`.
|
|
15
|
+
*
|
|
16
|
+
* Build parts in `added()`, not in the constructor: `this.add` / `this.make` belong to the view the entity is
|
|
17
|
+
* in, so they work once it has been added to one. Parts are parented to the entity; the resources they
|
|
18
|
+
* create are freed with the view, and model animations are driven by the view.
|
|
19
|
+
* Adding a pointer listener makes a passive entity interactive so it hears taps on its parts.
|
|
20
|
+
*/
|
|
21
|
+
export class Entity3D<Props = void> extends WithLifecycle(WithSignals(Container3D)) {
|
|
22
|
+
readonly props: Props;
|
|
23
|
+
private factories?: { owned: Owned3D; add: Factory3D; make: Factory3D };
|
|
24
|
+
|
|
25
|
+
constructor(props?: Props) {
|
|
26
|
+
super();
|
|
27
|
+
this.props = (props ?? {}) as Props;
|
|
28
|
+
bindAllMethods(this);
|
|
29
|
+
this._initLifecycle({ autoResize: false, autoUpdate: true });
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Fields, not methods, so `entity.on('pointertap', (e) => ...)` keeps upstream's per-event typing.
|
|
33
|
+
override on: Container3D['on'] & Listen<this> = ((event: string | symbol, fn: (...args: any[]) => void, context?: any) => {
|
|
34
|
+
this.enablePointerEvents(event);
|
|
35
|
+
return super.on(event, fn, context);
|
|
36
|
+
}) as Container3D['on'] & Listen<this>;
|
|
37
|
+
|
|
38
|
+
override once: Container3D['once'] & Listen<this> = ((event: string | symbol, fn: (...args: any[]) => void, context?: any) => {
|
|
39
|
+
this.enablePointerEvents(event);
|
|
40
|
+
return super.once(event, fn, context);
|
|
41
|
+
}) as Container3D['once'] & Listen<this>;
|
|
42
|
+
|
|
43
|
+
/** Listening is the opt-in: a passive ancestor is skipped when a part's event bubbles. */
|
|
44
|
+
private enablePointerEvents(event: string | symbol): void {
|
|
45
|
+
if (typeof event === 'string' && /^(global)?(pointer|mouse|touch)|^(click|tap|wheel|right|contextmenu)/.test(event) && this.eventMode === 'passive') {
|
|
46
|
+
this.eventMode = 'static';
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
get add(): Factory3D {
|
|
51
|
+
return this.resolveFactories().add;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
get make(): Factory3D {
|
|
55
|
+
return this.resolveFactories().make;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
private resolveFactories(): { add: Factory3D; make: Factory3D } {
|
|
59
|
+
const owned = this.view3d && ownedByView.get(this.view3d);
|
|
60
|
+
if (!owned) {
|
|
61
|
+
throw new Error(
|
|
62
|
+
'This Entity3D is not in a View3D yet. Build its parts in added(), after view.add.entity(...) or view.add.existing(entity).',
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
// Rebuilt only if the entity moved to another view.
|
|
66
|
+
if (this.factories?.owned !== owned) {
|
|
67
|
+
const view = this.view3d as View3D;
|
|
68
|
+
this.factories = { owned, add: createFactory3D(this, owned, true, view), make: createFactory3D(this, owned, false, view) };
|
|
69
|
+
}
|
|
70
|
+
return this.factories;
|
|
71
|
+
}
|
|
72
|
+
}
|
package/src/Model.ts
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { Model3D } from '@pixi/3d';
|
|
2
|
+
import type { AnimationState } from '@pixi/3d';
|
|
3
|
+
import type { Ticker } from 'pixi.js';
|
|
4
|
+
|
|
5
|
+
export type PlayOptions = Partial<Pick<AnimationState, 'loop' | 'animationSpeed' | 'weight' | 'onComplete' | 'onLoop'>> & {
|
|
6
|
+
fade?: number;
|
|
7
|
+
layer?: boolean;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
type Fade = { elapsed: number; duration: number; incoming: AnimationState; weight: number; outgoing: { state: AnimationState; weight: number }[] };
|
|
11
|
+
|
|
12
|
+
export class Model extends Model3D {
|
|
13
|
+
private transition?: Fade;
|
|
14
|
+
|
|
15
|
+
play(name: string, options: PlayOptions = {}): AnimationState {
|
|
16
|
+
const player = this.player;
|
|
17
|
+
if (!player || !player.animationLabels.includes(name)) {
|
|
18
|
+
throw new Error(
|
|
19
|
+
`Animation '${name}' is unavailable; available clips: ${player?.animationLabels.join(', ') || '(none)'}.`,
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
const state = player.getState(name);
|
|
23
|
+
const running = this.transition;
|
|
24
|
+
if (options.layer) {
|
|
25
|
+
// A fade under way carries on; it just lets go of this clip if it was fading it out.
|
|
26
|
+
if (running) running.outgoing = running.outgoing.filter((old) => old.state !== state);
|
|
27
|
+
} else {
|
|
28
|
+
// A fade under way ends here: its outgoing clips stop at once.
|
|
29
|
+
for (const { state: old } of running?.outgoing ?? []) {
|
|
30
|
+
old.onComplete = old.onLoop = null;
|
|
31
|
+
old.stop();
|
|
32
|
+
}
|
|
33
|
+
this.transition = undefined;
|
|
34
|
+
}
|
|
35
|
+
const outgoing = options.layer ? [] : player.animationLabels
|
|
36
|
+
.map((label) => player.getState(label))
|
|
37
|
+
.filter((other) => other !== state && other.playing);
|
|
38
|
+
for (const other of outgoing) other.onComplete = other.onLoop = null;
|
|
39
|
+
const { fade = 0, layer: _layer, ...stateOptions } = options;
|
|
40
|
+
Object.assign(state, { loop: true, animationSpeed: 1, weight: 1, onComplete: null, onLoop: null }, stateOptions);
|
|
41
|
+
if (fade > 0 && outgoing.length) {
|
|
42
|
+
this.transition = {
|
|
43
|
+
elapsed: 0, duration: fade, incoming: state, weight: state.weight,
|
|
44
|
+
outgoing: outgoing.map((other) => ({ state: other, weight: other.weight })),
|
|
45
|
+
};
|
|
46
|
+
state.weight = 0;
|
|
47
|
+
} else {
|
|
48
|
+
for (const other of outgoing) other.stop();
|
|
49
|
+
}
|
|
50
|
+
state.gotoAndPlay(0);
|
|
51
|
+
return state;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
stop(): void {
|
|
55
|
+
this.transition = undefined;
|
|
56
|
+
this.player?.stop();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
update(ticker: Ticker): void {
|
|
60
|
+
const fade = this.transition;
|
|
61
|
+
if (fade) {
|
|
62
|
+
fade.elapsed = Math.min(fade.duration, fade.elapsed + ticker.deltaMS / 1000);
|
|
63
|
+
const progress = fade.elapsed / fade.duration;
|
|
64
|
+
fade.incoming.weight = fade.weight * progress;
|
|
65
|
+
for (const { state, weight } of fade.outgoing) {
|
|
66
|
+
state.weight = weight * (1 - progress);
|
|
67
|
+
if (progress === 1) state.stop();
|
|
68
|
+
}
|
|
69
|
+
if (progress === 1) this.transition = undefined;
|
|
70
|
+
}
|
|
71
|
+
this.player?.update(ticker);
|
|
72
|
+
}
|
|
73
|
+
}
|
package/src/Plugin3D.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { Plugin, registerFactoryMethods } from '@caperjs/core';
|
|
2
|
+
import type { IApplication, IPlugin } from '@caperjs/core';
|
|
3
|
+
import { View3D } from './View3D';
|
|
4
|
+
import type { View3DProps } from './View3D';
|
|
5
|
+
import type { Quality3D } from './quality';
|
|
6
|
+
import { assertRenderer3D } from './renderer';
|
|
7
|
+
|
|
8
|
+
export interface Plugin3DOptions {
|
|
9
|
+
quality: Quality3D;
|
|
10
|
+
}
|
|
11
|
+
export interface IPlugin3D extends IPlugin<Plugin3DOptions> {}
|
|
12
|
+
|
|
13
|
+
declare module '@caperjs/core' {
|
|
14
|
+
interface FactoryMethodOverrides {
|
|
15
|
+
view3d: (props?: View3DProps) => View3D;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// One stable function, so a re-run `initialize` registers the same method again (a no-op) instead of colliding.
|
|
20
|
+
const view3d = (props?: View3DProps) => new View3D(props);
|
|
21
|
+
|
|
22
|
+
export class Plugin3D extends Plugin<Plugin3DOptions> implements IPlugin3D {
|
|
23
|
+
id = '3d';
|
|
24
|
+
|
|
25
|
+
initialize(options: Partial<Plugin3DOptions> = {}): void {
|
|
26
|
+
this._options = { quality: 'auto', ...options };
|
|
27
|
+
this.addDisposer(registerFactoryMethods({ view3d }));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
postInitialize(app: IApplication): void {
|
|
31
|
+
assertRenderer3D(app.renderer);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export default Plugin3D;
|
package/src/View3D.ts
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { Camera3D, Container3D, Vector3, View3D as PixiView3D } from '@pixi/3d';
|
|
2
|
+
import type { View3DOptions } from '@pixi/3d';
|
|
3
|
+
import { Application, bindAllMethods, isMobile, WithLifecycle, WithSignals } from '@caperjs/core';
|
|
4
|
+
import type { Size } from '@caperjs/core';
|
|
5
|
+
import { Point, type Container, type Ticker } from 'pixi.js';
|
|
6
|
+
import { fitFov } from './fit';
|
|
7
|
+
import { createFactory3D } from './factory3d';
|
|
8
|
+
import type { Factory3D } from './factory3d';
|
|
9
|
+
import { Owned3D, ownedByView } from './owned';
|
|
10
|
+
import type { Vec3 } from './props';
|
|
11
|
+
import { toVec3 } from './props';
|
|
12
|
+
import type { IPlugin3D } from './Plugin3D';
|
|
13
|
+
import { resolveQuality } from './quality';
|
|
14
|
+
import { assertRenderer3D } from './renderer';
|
|
15
|
+
|
|
16
|
+
type CameraProps = {
|
|
17
|
+
position?: Vec3;
|
|
18
|
+
lookAt?: Vec3;
|
|
19
|
+
/** Vertical field of view in degrees. */
|
|
20
|
+
fov?: number;
|
|
21
|
+
near?: number;
|
|
22
|
+
far?: number;
|
|
23
|
+
/**
|
|
24
|
+
* The aspect ratio (width / height) the scene is framed for, e.g. `16 / 9`. On a narrower view (a phone
|
|
25
|
+
* held upright) the field of view grows so the sides stay in frame. Leave it out to keep `fov` fixed.
|
|
26
|
+
*/
|
|
27
|
+
fitAspect?: number;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export interface View3DProps extends Omit<View3DOptions, 'camera'> {
|
|
31
|
+
camera?: Camera3D | CameraProps;
|
|
32
|
+
interactive?: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Something an add-on attaches to a view. The view drives it; games never call these. */
|
|
36
|
+
export interface View3DPart {
|
|
37
|
+
/** Every frame the view updates (on app.ticker), after model clips. */
|
|
38
|
+
update?(ticker: Ticker): void;
|
|
39
|
+
/** Once when owned with the current size, then on every view resize. */
|
|
40
|
+
resize?(size: Size): void;
|
|
41
|
+
/** Before the view frees its nodes and resources, while the view is still usable. */
|
|
42
|
+
destroy(): void;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const world = new Vector3();
|
|
46
|
+
const screen = new Point();
|
|
47
|
+
type Point3 = ReturnType<typeof toVec3>;
|
|
48
|
+
type Pin = { target: Container3D | Point3; offset: Point3 };
|
|
49
|
+
|
|
50
|
+
export class View3D extends WithLifecycle(WithSignals(PixiView3D)) {
|
|
51
|
+
readonly add: Factory3D;
|
|
52
|
+
readonly make: Factory3D;
|
|
53
|
+
private readonly owned = new Owned3D();
|
|
54
|
+
private readonly parts = new Set<View3DPart>();
|
|
55
|
+
private readonly pins = new Map<Container, Pin>();
|
|
56
|
+
private readonly fillsScreen: boolean;
|
|
57
|
+
private readonly fit?: { fov: number; aspect: number };
|
|
58
|
+
|
|
59
|
+
constructor(props: View3DProps = {}) {
|
|
60
|
+
const app = Application.getInstance();
|
|
61
|
+
assertRenderer3D(app.renderer);
|
|
62
|
+
const plugin = app.getPlugin<IPlugin3D>('3d');
|
|
63
|
+
const { camera, interactive = true, ...viewProps } = props;
|
|
64
|
+
const fillsScreen = props.width === undefined && props.height === undefined;
|
|
65
|
+
const quality = resolveQuality(plugin?.options.quality ?? 'auto', {
|
|
66
|
+
isMobile,
|
|
67
|
+
resolution: app.renderer.resolution,
|
|
68
|
+
});
|
|
69
|
+
super({
|
|
70
|
+
...quality,
|
|
71
|
+
environment: { skyColor: 0xbcd4f0, groundColor: 0x6b6250, intensity: 0.7 },
|
|
72
|
+
autoResize: fillsScreen,
|
|
73
|
+
// 'static' picks 3D and takes taps over the whole view; 'none' lets them reach 2D behind it.
|
|
74
|
+
// Never 'passive': it turns picking off and still blocks.
|
|
75
|
+
eventMode: interactive ? 'static' : 'none',
|
|
76
|
+
...viewProps,
|
|
77
|
+
...(camera instanceof Camera3D ? { camera } : {}),
|
|
78
|
+
});
|
|
79
|
+
this.fillsScreen = fillsScreen;
|
|
80
|
+
ownedByView.set(this, this.owned);
|
|
81
|
+
bindAllMethods(this);
|
|
82
|
+
this._initLifecycle({ autoResize: true, autoUpdate: true });
|
|
83
|
+
if (!(camera instanceof Camera3D)) {
|
|
84
|
+
this.camera.position = toVec3(camera?.position ?? [0, 1.5, 4]);
|
|
85
|
+
this.camera.lookAt(toVec3(camera?.lookAt ?? [0, 0, 0]));
|
|
86
|
+
if (camera?.fov !== undefined) this.camera.fov = camera.fov;
|
|
87
|
+
if (camera?.near !== undefined) this.camera.near = camera.near;
|
|
88
|
+
if (camera?.far !== undefined) this.camera.far = camera.far;
|
|
89
|
+
if (camera?.fitAspect) this.fit = { fov: this.camera.fov, aspect: camera.fitAspect };
|
|
90
|
+
}
|
|
91
|
+
this.add = createFactory3D(this.root, this.owned, true, this);
|
|
92
|
+
this.make = createFactory3D(this.root, this.owned, false, this);
|
|
93
|
+
this.fitCamera();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** For add-ons: the view updates `part` every frame, tells it about resizes, and destroys it with itself. */
|
|
97
|
+
own<T extends View3DPart>(part: T): T {
|
|
98
|
+
this.parts.add(part);
|
|
99
|
+
part.resize?.(this.viewSize);
|
|
100
|
+
return part;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Keeps a 2D node over a 3D point, hidden while the point is behind the camera. Returns the function that unpins it. */
|
|
104
|
+
pin(node: Container, target: Container3D | Vec3, options: { offset?: Vec3 } = {}): () => void {
|
|
105
|
+
const pin: Pin = { target: target instanceof Container3D ? target : toVec3(target), offset: toVec3(options.offset ?? [0, 0, 0]) };
|
|
106
|
+
this.pins.set(node, pin);
|
|
107
|
+
// A pin hides its node behind the camera, so a pin that ends shows it again.
|
|
108
|
+
return () => {
|
|
109
|
+
if (this.pins.get(node) !== pin) return;
|
|
110
|
+
this.pins.delete(node);
|
|
111
|
+
node.visible = true;
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
added(): void {
|
|
116
|
+
this.resize();
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
resize(_size?: Size): void {
|
|
120
|
+
this.placeFullScreen();
|
|
121
|
+
this.fitCamera();
|
|
122
|
+
for (const part of this.parts) part.resize?.(this.viewSize);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
update(_ticker?: Ticker | number): void {
|
|
126
|
+
this.owned.update(this.app.ticker);
|
|
127
|
+
for (const part of this.parts) part.update?.(this.app.ticker);
|
|
128
|
+
this.placePins();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
private placePins(): void {
|
|
132
|
+
for (const [node, { target, offset }] of this.pins) {
|
|
133
|
+
const node3d = target instanceof Container3D ? target : null;
|
|
134
|
+
if (node.destroyed || node3d?.destroyed) {
|
|
135
|
+
this.pins.delete(node);
|
|
136
|
+
if (!node.destroyed) node.visible = true;
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
const at = node3d ? node3d.getGlobalPosition(world) : (target as Point3);
|
|
140
|
+
world.set(at.x + offset.x, at.y + offset.y, at.z + offset.z);
|
|
141
|
+
// null: the point is at or behind the camera.
|
|
142
|
+
const point = this.worldToScreen(world, screen);
|
|
143
|
+
node.visible = point !== null;
|
|
144
|
+
if (!point) continue;
|
|
145
|
+
if (node.parent) node.parent.toLocal(point, undefined, node.position);
|
|
146
|
+
else node.position.copyFrom(point);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
destroy(_options?: Parameters<PixiView3D['destroy']>[0] | boolean): void {
|
|
151
|
+
this.pins.clear();
|
|
152
|
+
for (const part of this.parts) part.destroy();
|
|
153
|
+
this.parts.clear();
|
|
154
|
+
// glTF sources share materials; destroying them crashes the next visit.
|
|
155
|
+
super.destroy({ children: true });
|
|
156
|
+
this.owned.destroy();
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
private fitCamera(): void {
|
|
160
|
+
if (!this.fit) return;
|
|
161
|
+
const { width, height } = this.viewSize;
|
|
162
|
+
this.camera.fov = fitFov(this.fit.fov, width / height, this.fit.aspect);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
private get viewSize(): Size {
|
|
166
|
+
const size = this.fillsScreen ? this.app.size : this;
|
|
167
|
+
return { width: size.width, height: size.height };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
private placeFullScreen(): void {
|
|
171
|
+
if (this.fillsScreen) this.position.set(-this.app.size.width / 2, -this.app.size.height / 2);
|
|
172
|
+
}
|
|
173
|
+
}
|
package/src/factory3d.ts
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AmbientLight,
|
|
3
|
+
Camera3D,
|
|
4
|
+
Container3D,
|
|
5
|
+
CubeGeometry,
|
|
6
|
+
CylinderGeometry,
|
|
7
|
+
DirectionalLight,
|
|
8
|
+
Material3D,
|
|
9
|
+
Mesh3D,
|
|
10
|
+
PlaneGeometry,
|
|
11
|
+
PointLight,
|
|
12
|
+
SphereGeometry,
|
|
13
|
+
SpotLight,
|
|
14
|
+
} from '@pixi/3d';
|
|
15
|
+
import type { Geometry3D, Light, Scene3DSource } from '@pixi/3d';
|
|
16
|
+
import { Assets } from 'pixi.js';
|
|
17
|
+
import { getEntityCtor, getRegisteredEntityIds } from '@caperjs/core';
|
|
18
|
+
import { Model } from './Model';
|
|
19
|
+
import { Owned3D } from './owned';
|
|
20
|
+
import type {
|
|
21
|
+
Camera3DProps,
|
|
22
|
+
Entity3DId,
|
|
23
|
+
Entity3DInstance,
|
|
24
|
+
Entity3DNodeProps,
|
|
25
|
+
Entity3DProps,
|
|
26
|
+
Light3DProps,
|
|
27
|
+
Material3DProps,
|
|
28
|
+
Mesh3DProps,
|
|
29
|
+
Model3DProps,
|
|
30
|
+
Node3DProps,
|
|
31
|
+
} from './props';
|
|
32
|
+
import { resolveNodeOptions, toVec3 } from './props';
|
|
33
|
+
import type { View3D } from './View3D';
|
|
34
|
+
|
|
35
|
+
/** Add-on methods on every 3D factory. Augment with the call-site shape:
|
|
36
|
+
* declare module '@caperjs/plugin-3d' { interface Factory3DOverrides { cameraRig: (props?: P) => CameraRig } } */
|
|
37
|
+
export interface Factory3DOverrides {}
|
|
38
|
+
|
|
39
|
+
/** Registered methods receive the view before their call-site arguments. */
|
|
40
|
+
export type Factory3DMethods = {
|
|
41
|
+
[K in keyof Factory3DOverrides]: Factory3DOverrides[K] extends (...args: infer A) => infer R
|
|
42
|
+
? (view: View3D, ...args: A) => R : never;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
interface BuiltInFactory3D {
|
|
46
|
+
container(props?: Node3DProps): Container3D;
|
|
47
|
+
mesh(props?: Mesh3DProps): Mesh3D;
|
|
48
|
+
/** A material owned by the view: freed with it. Pass it to many meshes. A material has no parent, so add.material and make.material are the same. */
|
|
49
|
+
material(props?: Material3DProps): Material3D;
|
|
50
|
+
entity<K extends Entity3DId>(id: K, props?: Entity3DProps<K> & Entity3DNodeProps): Entity3DInstance<K>;
|
|
51
|
+
model(props: Model3DProps): Model;
|
|
52
|
+
light(props?: Light3DProps): Light;
|
|
53
|
+
camera(props?: Camera3DProps): Camera3D;
|
|
54
|
+
existing<T extends Container3D>(node: T, props?: Node3DProps): T;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface Factory3D extends BuiltInFactory3D, Factory3DOverrides {}
|
|
58
|
+
|
|
59
|
+
const BUILT_IN_METHODS = {
|
|
60
|
+
container: true, mesh: true, material: true, entity: true,
|
|
61
|
+
model: true, light: true, camera: true, existing: true,
|
|
62
|
+
} satisfies Record<keyof BuiltInFactory3D, true>;
|
|
63
|
+
const registered = new Map<string, (...args: any[]) => any>();
|
|
64
|
+
|
|
65
|
+
/** Register methods for factories built afterwards. Register before creating views: a view builds its
|
|
66
|
+
* factories in its constructor, and an entity builds them on first add/make use. A disposer only affects
|
|
67
|
+
* future factories. Registered methods have the same behavior on add and make; neither parents their result.
|
|
68
|
+
*/
|
|
69
|
+
export function registerFactory3DMethods(methods: Partial<Factory3DMethods>): () => void {
|
|
70
|
+
const entries = Object.entries(methods) as Array<[string, (...args: any[]) => any]>;
|
|
71
|
+
for (const [name, method] of entries) {
|
|
72
|
+
if (Object.hasOwn(BUILT_IN_METHODS, name)) {
|
|
73
|
+
throw new Error(`[caper] registerFactory3DMethods: "${name}" is a built-in 3D factory method and cannot be replaced. Pick another name.`);
|
|
74
|
+
}
|
|
75
|
+
if (typeof method !== 'function') {
|
|
76
|
+
throw new Error(`[caper] registerFactory3DMethods: "${name}" must be a function.`);
|
|
77
|
+
}
|
|
78
|
+
const previous = registered.get(name);
|
|
79
|
+
if (previous && previous !== method) {
|
|
80
|
+
throw new Error(`[caper] registerFactory3DMethods: "${name}" is already registered (by function "${previous.name || 'anonymous'}"); cannot register function "${method.name || 'anonymous'}" under the same name.`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
const added = entries.filter(([name]) => !registered.has(name));
|
|
84
|
+
for (const [name, method] of added) registered.set(name, method);
|
|
85
|
+
return () => {
|
|
86
|
+
for (const [name, method] of added) {
|
|
87
|
+
if (registered.get(name) === method) registered.delete(name);
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const BUILT_IN_GEOMETRY = {
|
|
93
|
+
box: () => new CubeGeometry(),
|
|
94
|
+
cylinder: () => new CylinderGeometry({ radiusTop: 0.5, radiusBottom: 0.5, height: 1 }),
|
|
95
|
+
plane: () => new PlaneGeometry(),
|
|
96
|
+
sphere: () => new SphereGeometry({ radius: 0.5 }),
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const ENTITY_PLACEMENT = ['position', 'rotation', 'scale', 'x', 'y', 'z', 'visible', 'lookAt', 'parent'] as const;
|
|
100
|
+
|
|
101
|
+
const LIGHTS = { directional: DirectionalLight, point: PointLight, spot: SpotLight, ambient: AmbientLight };
|
|
102
|
+
|
|
103
|
+
export function createFactory3D(root: Container3D, owned: Owned3D, parent: boolean, view: View3D): Factory3D {
|
|
104
|
+
const options = <T extends Node3DProps>(props: T) => {
|
|
105
|
+
const resolved = resolveNodeOptions(props);
|
|
106
|
+
return parent && !resolved.parent ? { ...resolved, parent: root } : resolved;
|
|
107
|
+
};
|
|
108
|
+
const finish = <T extends Container3D>(node: T, props: Node3DProps): T => {
|
|
109
|
+
if (props.lookAt) node.lookAt(toVec3(props.lookAt));
|
|
110
|
+
return node;
|
|
111
|
+
};
|
|
112
|
+
const existing = <T extends Container3D>(node: T, props: Node3DProps = {}): T => {
|
|
113
|
+
const { parent: to = root, ...rest } = resolveNodeOptions(props);
|
|
114
|
+
Object.assign(node, rest);
|
|
115
|
+
if (parent) to.addChild(node);
|
|
116
|
+
// After parenting: lookAt aims in world space.
|
|
117
|
+
return finish(node, props);
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
const builtIns: BuiltInFactory3D = {
|
|
121
|
+
container(props = {}) {
|
|
122
|
+
return finish(new Container3D(options(props)), props);
|
|
123
|
+
},
|
|
124
|
+
material(props = {}) {
|
|
125
|
+
const { color, ...options } = props;
|
|
126
|
+
return owned.material(new Material3D(color === undefined ? options : { baseColor: color, ...options }));
|
|
127
|
+
},
|
|
128
|
+
entity(id, props) {
|
|
129
|
+
const Ctor = getEntityCtor(id);
|
|
130
|
+
if (!Ctor) {
|
|
131
|
+
const known = getRegisteredEntityIds();
|
|
132
|
+
throw new Error(
|
|
133
|
+
`Unknown entity id '${id}'. Known: ${known.length ? known.map((key) => `'${key}'`).join(', ') : '(none)'}`,
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
if (!(Ctor.prototype instanceof Container3D)) {
|
|
137
|
+
throw new Error(
|
|
138
|
+
`Entity '${id}' is not a 3D entity (its class does not extend Container3D). Use this.add.entity for 2D entities.`,
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
const node = new (Ctor as new (props?: unknown) => Container3D)(props);
|
|
142
|
+
// Only placement is applied to the node; every other prop is the entity's own.
|
|
143
|
+
const placement: Entity3DNodeProps = Object.fromEntries(
|
|
144
|
+
ENTITY_PLACEMENT.filter((key) => props && key in props).map((key) => [key, props![key]]),
|
|
145
|
+
);
|
|
146
|
+
return existing(node, placement) as Entity3DInstance<typeof id>;
|
|
147
|
+
},
|
|
148
|
+
mesh(props = {}) {
|
|
149
|
+
const { geometry = 'box', material, color, roughness, metallic, ...nodeProps } = props;
|
|
150
|
+
let actualGeometry: Geometry3D;
|
|
151
|
+
if (typeof geometry !== 'string') actualGeometry = geometry;
|
|
152
|
+
else if (geometry in BUILT_IN_GEOMETRY) actualGeometry = owned.geometry(geometry, BUILT_IN_GEOMETRY[geometry]);
|
|
153
|
+
else throw new Error(`Unknown 3D geometry '${geometry}'. Use box, plane, sphere, or cylinder.`);
|
|
154
|
+
const actualMaterial = material ?? owned.material(new Material3D({
|
|
155
|
+
baseColor: color ?? 0xffffff,
|
|
156
|
+
...(roughness !== undefined && { roughness }),
|
|
157
|
+
...(metallic !== undefined && { metallic }),
|
|
158
|
+
}));
|
|
159
|
+
return finish(new Mesh3D({ ...options(nodeProps), geometry: actualGeometry, material: actualMaterial }), props);
|
|
160
|
+
},
|
|
161
|
+
model(props) {
|
|
162
|
+
const { asset, ...nodeProps } = props;
|
|
163
|
+
const source = typeof asset === 'string' ? Assets.get<Scene3DSource>(asset) : asset;
|
|
164
|
+
if (!source)
|
|
165
|
+
throw new Error(`Model asset '${asset}' is not loaded. Add its bundle to the scene's assets.preload.`);
|
|
166
|
+
const model = finish(new Model({ ...options(nodeProps), source, autoUpdate: false }), props);
|
|
167
|
+
if (model.player) owned.player(model);
|
|
168
|
+
return model;
|
|
169
|
+
},
|
|
170
|
+
light(props = {}) {
|
|
171
|
+
const { type = 'directional', ...nodeProps } = props;
|
|
172
|
+
const lightOptions = options(nodeProps);
|
|
173
|
+
if (type === 'directional' && !props.rotation && !props.quaternion && !props.lookAt) {
|
|
174
|
+
lightOptions.rotation = { x: -Math.PI / 4, y: Math.PI / 6, z: 0 };
|
|
175
|
+
}
|
|
176
|
+
const light = new LIGHTS[type](lightOptions);
|
|
177
|
+
return finish(light, props);
|
|
178
|
+
},
|
|
179
|
+
camera(props = {}) {
|
|
180
|
+
return finish(new Camera3D(options(props)), props);
|
|
181
|
+
},
|
|
182
|
+
existing,
|
|
183
|
+
};
|
|
184
|
+
const bound = Object.fromEntries(
|
|
185
|
+
[...registered].map(([name, method]) => [name, (...args: any[]) => method(view, ...args)]),
|
|
186
|
+
);
|
|
187
|
+
return Object.assign(builtIns, bound) as Factory3D;
|
|
188
|
+
}
|
package/src/fit.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The vertical field of view (degrees) that keeps everything a `fitAspect` screen would show in frame.
|
|
3
|
+
* Screens at least as wide as `fitAspect` keep `fov`; narrower ones (portrait) get a taller field of view,
|
|
4
|
+
* so the sides are not cropped. Without `fitAspect` the answer is always `fov`.
|
|
5
|
+
*/
|
|
6
|
+
export function fitFov(fov: number, aspect: number, fitAspect?: number): number {
|
|
7
|
+
if (!fitAspect || !(aspect > 0) || aspect >= fitAspect) return fov;
|
|
8
|
+
const halfTan = Math.tan((fov * Math.PI) / 360);
|
|
9
|
+
return (2 * Math.atan((halfTan * fitAspect) / aspect) * 180) / Math.PI;
|
|
10
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export { default, Plugin3D } from './Plugin3D';
|
|
2
|
+
export type { IPlugin3D, Plugin3DOptions } from './Plugin3D';
|
|
3
|
+
export { View3D } from './View3D';
|
|
4
|
+
export { Entity3D } from './Entity3D';
|
|
5
|
+
export type { View3DProps, View3DPart } from './View3D';
|
|
6
|
+
export { Model } from './Model';
|
|
7
|
+
export type { PlayOptions } from './Model';
|
|
8
|
+
export { registerFactory3DMethods } from './factory3d';
|
|
9
|
+
export type { Factory3D, Factory3DOverrides, Factory3DMethods } from './factory3d';
|
|
10
|
+
export { fitFov } from './fit';
|
|
11
|
+
export type { Node3DProps, Mesh3DProps, Model3DProps, ModelAsset, Light3DProps, Camera3DProps, Vec3 } from './props';
|
|
12
|
+
export type { Entity3DId, Entity3DNodeProps, Material3DProps } from './props';
|
|
13
|
+
export type { Quality3D } from './quality';
|