Skip to content

Commit 3380d96

Browse files
authored
feat(engine): implement CameraComponent with viewport and depth support (#242)
1 parent 69f882e commit 3380d96

File tree

10 files changed

+449
-55
lines changed

10 files changed

+449
-55
lines changed

.changeset/plain-clowns-exist.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@jolly-pixel/engine": minor
3+
---
4+
5+
Implement hybrid CameraComponent with viewport and depth support
Lines changed: 261 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,261 @@
1+
// Import Third-party Dependencies
2+
import * as THREE from "three";
3+
4+
// Import Internal Dependencies
5+
import { ActorComponent } from "../../actor/ActorComponent.ts";
6+
import type { Actor } from "../../actor/Actor.ts";
7+
import type {
8+
RenderComponent,
9+
RenderViewport
10+
} from "../../systems/rendering/Renderer.ts";
11+
import type { WorldDefaultContext } from "../../systems/World.ts";
12+
13+
// CONSTANTS
14+
export type CameraProjectionMode = "perspective" | "orthographic";
15+
16+
export interface CameraOptions {
17+
projectionMode?: CameraProjectionMode;
18+
/**
19+
* Perspective FOV in degrees.
20+
* @default 45
21+
**/
22+
fov?: number;
23+
/** Near clipping plane.
24+
* @default 0.1
25+
**/
26+
near?: number;
27+
/** Far clipping plane.
28+
* @default 10000
29+
**/
30+
far?: number;
31+
/** Ortho mode half-height in world units.
32+
* @default 1
33+
**/
34+
orthographicScale?: number;
35+
/** Normalized viewport rect. null = full canvas.
36+
* @default null
37+
**/
38+
viewport?: RenderViewport | null;
39+
/** Render depth/order. Lower = rendered first.
40+
* @default 0
41+
**/
42+
depth?: number;
43+
/**
44+
* Whether to automatically add a THREE.AudioListener to the camera for 3D audio.
45+
* @default false
46+
*/
47+
addAudioListener?: boolean;
48+
}
49+
50+
export class CameraComponent<
51+
TContext = WorldDefaultContext
52+
> extends ActorComponent<TContext> implements RenderComponent {
53+
#threeCamera: THREE.PerspectiveCamera | THREE.OrthographicCamera;
54+
#projectionMode: CameraProjectionMode;
55+
#fov: number;
56+
#near: number;
57+
#far: number;
58+
#orthographicScale: number;
59+
#viewport: RenderViewport | null;
60+
#depth: number;
61+
62+
#projectionDirty = true;
63+
#lastCanvasWidth = 0;
64+
#lastCanvasHeight = 0;
65+
66+
get threeCamera(): THREE.PerspectiveCamera | THREE.OrthographicCamera {
67+
return this.#threeCamera;
68+
}
69+
70+
get depth(): number {
71+
return this.#depth;
72+
}
73+
74+
get viewport(): RenderViewport | null {
75+
return this.#viewport;
76+
}
77+
78+
get projectionMode(): CameraProjectionMode {
79+
return this.#projectionMode;
80+
}
81+
82+
get fov(): number {
83+
return this.#fov;
84+
}
85+
86+
get near(): number {
87+
return this.#near;
88+
}
89+
90+
get far(): number {
91+
return this.#far;
92+
}
93+
94+
get orthographicScale(): number {
95+
return this.#orthographicScale;
96+
}
97+
98+
constructor(
99+
actor: Actor<TContext>,
100+
options: CameraOptions = {}
101+
) {
102+
super({
103+
actor,
104+
typeName: "Camera"
105+
});
106+
107+
const {
108+
projectionMode = "perspective",
109+
fov = 45,
110+
near = 0.1,
111+
far = 10000,
112+
orthographicScale = 1,
113+
viewport = null,
114+
depth = 0,
115+
addAudioListener = false
116+
} = options;
117+
118+
this.#projectionMode = projectionMode;
119+
this.#fov = fov;
120+
this.#near = near;
121+
this.#far = far;
122+
this.#orthographicScale = orthographicScale;
123+
this.#viewport = viewport;
124+
this.#depth = depth;
125+
126+
this.#threeCamera = this.#projectionMode === "orthographic"
127+
? new THREE.OrthographicCamera(-1, 1, 1, -1, this.#near, this.#far)
128+
: new THREE.PerspectiveCamera(this.#fov, 1, this.#near, this.#far);
129+
if (addAudioListener) {
130+
this.threeCamera.add(actor.world.audio.threeAudioListener);
131+
}
132+
}
133+
134+
awake(): void {
135+
this.actor.world.renderer.addRenderComponent(this);
136+
}
137+
138+
/**
139+
* Called by RenderStrategy every frame before draw.
140+
* Syncs transform + updates projection.
141+
**/
142+
prepareRender(
143+
canvasWidth: number,
144+
canvasHeight: number
145+
): void {
146+
// Sync camera world transform from actor's scene graph
147+
this.actor.object3D.updateWorldMatrix(true, false);
148+
this.#threeCamera.matrixWorld.copy(
149+
this.actor.object3D.matrixWorld
150+
);
151+
this.#threeCamera.matrixWorldInverse
152+
.copy(this.#threeCamera.matrixWorld)
153+
.invert();
154+
155+
// Update projection when canvas resizes or settings changed
156+
const canvasChanged = canvasWidth !== this.#lastCanvasWidth ||
157+
canvasHeight !== this.#lastCanvasHeight;
158+
if (this.#projectionDirty || canvasChanged) {
159+
this.#lastCanvasWidth = canvasWidth;
160+
this.#lastCanvasHeight = canvasHeight;
161+
this.#updateProjection(canvasWidth, canvasHeight);
162+
this.#projectionDirty = false;
163+
}
164+
}
165+
166+
#updateProjection(
167+
canvasWidth: number,
168+
canvasHeight: number
169+
): void {
170+
const viewport = this.#viewport;
171+
const effectiveWidth = viewport ? viewport.width * canvasWidth : canvasWidth;
172+
const effectiveHeight = viewport ? viewport.height * canvasHeight : canvasHeight;
173+
const aspect = effectiveWidth / Math.max(effectiveHeight, 1);
174+
175+
if (this.#threeCamera instanceof THREE.PerspectiveCamera) {
176+
this.#threeCamera.fov = this.#fov;
177+
this.#threeCamera.aspect = aspect;
178+
this.#threeCamera.near = this.#near;
179+
this.#threeCamera.far = this.#far;
180+
}
181+
else {
182+
const halfHeight = this.#orthographicScale;
183+
const halfWidth = halfHeight * aspect;
184+
this.#threeCamera.left = -halfWidth;
185+
this.#threeCamera.right = halfWidth;
186+
this.#threeCamera.top = halfHeight;
187+
this.#threeCamera.bottom = -halfHeight;
188+
this.#threeCamera.near = this.#near;
189+
this.#threeCamera.far = this.#far;
190+
}
191+
192+
this.#threeCamera.updateProjectionMatrix();
193+
}
194+
195+
setFov(
196+
fov: number
197+
): this {
198+
this.#fov = fov;
199+
this.#projectionDirty = true;
200+
201+
return this;
202+
}
203+
204+
setNearFar(
205+
near: number,
206+
far: number
207+
): this {
208+
this.#near = near;
209+
this.#far = far;
210+
this.#projectionDirty = true;
211+
212+
return this;
213+
}
214+
215+
setOrthographicScale(
216+
scale: number
217+
): this {
218+
this.#orthographicScale = scale;
219+
this.#projectionDirty = true;
220+
221+
return this;
222+
}
223+
224+
setViewport(
225+
viewport: RenderViewport | null
226+
): this {
227+
this.#viewport = viewport;
228+
this.#projectionDirty = true;
229+
230+
return this;
231+
}
232+
233+
setDepth(
234+
depth: number
235+
): this {
236+
this.#depth = depth;
237+
238+
return this;
239+
}
240+
241+
setProjectionMode(
242+
mode: CameraProjectionMode
243+
): this {
244+
if (mode === this.#projectionMode) {
245+
return this;
246+
}
247+
248+
this.#projectionMode = mode;
249+
this.#threeCamera = mode === "orthographic"
250+
? new THREE.OrthographicCamera(-1, 1, 1, -1, this.#near, this.#far)
251+
: new THREE.PerspectiveCamera(this.#fov, 1, this.#near, this.#far);
252+
this.#projectionDirty = true;
253+
254+
return this;
255+
}
256+
257+
override destroy(): void {
258+
this.actor.world.renderer.removeRenderComponent(this);
259+
super.destroy();
260+
}
261+
}

packages/engine/src/components/camera/Camera3DControls.ts

Lines changed: 45 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -5,26 +5,28 @@ import * as THREE from "three";
55
import type { InputKeyboardAction } from "../../controls/types.ts";
66
import type { MouseEventButton } from "../../controls/Input.class.ts";
77
import { Actor } from "../../actor/Actor.ts";
8-
import { Behavior } from "../script/Behavior.ts";
8+
import { CameraComponent, type CameraOptions } from "./Camera.ts";
99

10-
export interface Camera3DControlsOptions {
10+
export interface Camera3DControlsOptions extends CameraOptions {
1111
bindings?: {
1212
forward?: InputKeyboardAction;
1313
backward?: InputKeyboardAction;
1414
left?: InputKeyboardAction;
1515
right?: InputKeyboardAction;
1616
up?: InputKeyboardAction;
1717
down?: InputKeyboardAction;
18-
lookAround?: Exclude<keyof typeof MouseEventButton, "scrollUp" | "scrollDown">;
18+
lookAround?: Exclude<
19+
keyof typeof MouseEventButton,
20+
"scrollUp" | "scrollDown"
21+
>;
1922
};
2023
maxRollUp?: number;
2124
maxRollDown?: number;
2225
rotationSpeed?: number;
2326
speed?: number;
2427
}
2528

26-
export class Camera3DControls extends Behavior {
27-
camera: THREE.PerspectiveCamera;
29+
export class Camera3DControls extends CameraComponent<any> {
2830
#bindings: Required<NonNullable<Camera3DControlsOptions["bindings"]>>;
2931

3032
maxRollUp: number;
@@ -36,27 +38,41 @@ export class Camera3DControls extends Behavior {
3638
actor: Actor<any>,
3739
options: Camera3DControlsOptions = {}
3840
) {
39-
super(actor);
40-
41-
this.camera = new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 1, 10000);
41+
super(actor, {
42+
addAudioListener: true
43+
});
44+
45+
const {
46+
bindings,
47+
maxRollUp = Math.PI / 2,
48+
maxRollDown = -Math.PI / 2,
49+
rotationSpeed = 1,
50+
speed = 7.5
51+
} = options;
4252

4353
this.#bindings = {
44-
forward: options.bindings?.forward ?? "KeyW",
45-
backward: options.bindings?.backward ?? "KeyS",
46-
left: options.bindings?.left ?? "KeyA",
47-
right: options.bindings?.right ?? "KeyD",
48-
up: options.bindings?.up ?? "Space",
49-
down: options.bindings?.down ?? "ShiftLeft",
50-
lookAround: options.bindings?.lookAround ?? "middle"
54+
forward: bindings?.forward ?? "KeyW",
55+
backward: bindings?.backward ?? "KeyS",
56+
left: bindings?.left ?? "KeyA",
57+
right: bindings?.right ?? "KeyD",
58+
up: bindings?.up ?? "Space",
59+
down: bindings?.down ?? "ShiftLeft",
60+
lookAround: bindings?.lookAround ?? "middle"
5161
};
5262

53-
this.maxRollUp = options.maxRollUp ?? Math.PI / 2;
54-
this.maxRollDown = options.maxRollDown ?? -Math.PI / 2;
55-
this.#rotationSpeed = options.rotationSpeed ?? 0.004;
56-
this.#movementSpeed = options.speed ?? 20;
63+
this.maxRollUp = maxRollUp;
64+
this.maxRollDown = maxRollDown;
65+
this.#rotationSpeed = rotationSpeed;
66+
this.#movementSpeed = speed;
67+
}
68+
69+
get camera(): THREE.PerspectiveCamera {
70+
return this.threeCamera as THREE.PerspectiveCamera;
71+
}
5772

58-
this.actor.world.renderer.addRenderComponent(this.camera);
59-
this.camera.add(this.actor.world.audio.threeAudioListener);
73+
override awake(): void {
74+
super.awake();
75+
this.needUpdate = true;
6076
}
6177

6278
set speed(
@@ -85,7 +101,9 @@ export class Camera3DControls extends Behavior {
85101
this.camera.quaternion.setFromEuler(euler);
86102
}
87103

88-
update() {
104+
update(
105+
deltaTime: number
106+
) {
89107
const { input } = this.actor.world;
90108

91109
const vector = new THREE.Vector3(0);
@@ -111,8 +129,11 @@ export class Camera3DControls extends Behavior {
111129
}
112130

113131
const translation = new THREE.Vector3(vector.x, 0, vector.z);
114-
this.camera.translateOnAxis(translation.normalize(), this.#movementSpeed);
115-
this.camera.position.y += vector.y * this.#movementSpeed;
132+
this.camera.translateOnAxis(
133+
translation.normalize(),
134+
this.#movementSpeed * deltaTime
135+
);
136+
this.camera.position.y += vector.y * this.#movementSpeed * deltaTime;
116137

117138
if (input.isMouseButtonDown(this.#bindings.lookAround)) {
118139
// input.mouse.lock();

packages/engine/src/components/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
export * from "./camera/Camera.ts";
12
export * from "./camera/Camera3DControls.ts";
23
export * from "./renderers/index.ts";
34

0 commit comments

Comments
 (0)