Skip to content

Commit f4946ce

Browse files
authored
feat: virtual camera mouselook (locked PrimaryPointerInfo delta) (#76)
1 parent 65b7269 commit f4946ce

10 files changed

Lines changed: 407 additions & 0 deletions

File tree

dcl-workspace.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,9 @@
9999
{
100100
"path": "scenes/4,19-avatar-shape-movement"
101101
},
102+
{
103+
"path": "scenes/32,20-virtual-camera-mouse-look"
104+
},
102105
{
103106
"path": "scenes/31,20-pointer-lock-control"
104107
},
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
.*
2+
bin/*.map
3+
package-lock.json
4+
yarn-lock.json
5+
build.json
6+
export
7+
tsconfig.json
8+
tslint.json
9+
node_modules
10+
*.ts
11+
*.tsx
12+
.vscode
13+
Dockerfile
14+
dist
15+
README.md
16+
*.blend
17+
*.fbx
18+
*.zip
19+
*.rar
20+
src
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
package-lock.json
2+
*.js
3+
node_modules
4+
bin/
5+
.DS_Store
6+
**/.DS_Store
7+
npm-debug.log*
8+
yarn-debug.log*
9+
yarn-error.log*
10+
.editor
11+
.editor
12+
.idea
13+
.vscode
14+
dclcontext
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
This scene showcases virtual camera control driven by mouse pointer delta while the pointer is locked.
2+
3+
It relies on `PrimaryPointerInfo.screenDelta`, which keeps reporting raw mouse delta even while the cursor
4+
is locked (a renderer-side fix, shipped separately from this test scene). While the pointer is locked,
5+
`screenCoordinates` reports the screen center and `worldRayDirection` reports the center ray.
6+
7+
Click the green box to activate the virtual camera and disable player input. Lock the pointer (click inside
8+
the scene or use the HUD button) to start steering the camera with mouse movement: moving the mouse rotates
9+
the camera's yaw/pitch. A ring of colored boxes and N/E/S/W text markers around the camera make the rotation
10+
easy to follow.
11+
12+
Pressing F or right-clicking (IA_SECONDARY) while the camera is active exits mouse-look mode, restores
13+
player input, and returns control to the default camera.
14+
15+
The HUD panel shows live pointer-lock status, the current screenDelta, and the accumulated yaw/pitch.

scenes/32,20-virtual-camera-mouse-look/main.crdt

Whitespace-only changes.
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
{
2+
"name": "virtual-camera-mouse-look-test",
3+
"version": "1.0.0",
4+
"description": "Virtual Camera Mouse Look",
5+
"scripts": {
6+
"start": "sdk-commands start",
7+
"deploy": "sdk-commands deploy",
8+
"build": "sdk-commands build",
9+
"upgrade-sdk": "npm install --save-dev @dcl/sdk@latest",
10+
"upgrade-sdk:next": "npm install --save-dev @dcl/sdk@next"
11+
},
12+
"devDependencies": {
13+
"@dcl/js-runtime": "7.0.0",
14+
"@dcl/sdk": "^7.24.4"
15+
},
16+
"engines": {
17+
"node": ">=16.0.0",
18+
"npm": ">=6.0.0"
19+
},
20+
"prettier": {
21+
"semi": false,
22+
"singleQuote": true,
23+
"printWidth": 120,
24+
"trailingComma": "none"
25+
}
26+
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
{
2+
"ecs7": true,
3+
"runtimeVersion": "7",
4+
"display": {
5+
"title": "Virtual Camera Mouse Look",
6+
"description": "Showcases virtual camera control driven by PrimaryPointerInfo.screenDelta while the pointer is locked",
7+
"favicon": "favicon_asset",
8+
"navmapThumbnail": ""
9+
},
10+
"owner": "",
11+
"contact": {
12+
"name": "SDK",
13+
"email": ""
14+
},
15+
"main": "bin/index.js",
16+
"tags": [],
17+
"scene": {
18+
"parcels": [
19+
"32,20"
20+
],
21+
"base": "32,20"
22+
},
23+
"spawnPoints": [
24+
{
25+
"name": "spawn1",
26+
"default": true,
27+
"position": {
28+
"x": [
29+
0,
30+
3
31+
],
32+
"y": [
33+
0,
34+
0
35+
],
36+
"z": [
37+
0,
38+
3
39+
]
40+
},
41+
"cameraTarget": {
42+
"x": 8,
43+
"y": 1,
44+
"z": 8
45+
}
46+
}
47+
],
48+
"requiredPermissions": [
49+
"ALLOW_TO_TRIGGER_AVATAR_EMOTE",
50+
"ALLOW_TO_MOVE_PLAYER_INSIDE_SCENE"
51+
],
52+
"featureToggles": {
53+
"voiceChat": "enabled",
54+
"portableExperiences": "enabled"
55+
},
56+
"skyboxConfig": {},
57+
"worldConfiguration": {
58+
"name": "sdk7testscenes.dcl.eth"
59+
}
60+
}
Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
import {
2+
engine,
3+
Entity,
4+
Transform,
5+
MeshRenderer,
6+
MeshCollider,
7+
Material,
8+
TextShape,
9+
VirtualCamera,
10+
MainCamera,
11+
InputModifier,
12+
PointerLock,
13+
PrimaryPointerInfo,
14+
pointerEventsSystem,
15+
InputAction,
16+
inputSystem,
17+
PointerEventType
18+
} from '@dcl/sdk/ecs'
19+
import { Vector3, Quaternion, Color4 } from '@dcl/sdk/math'
20+
import { setupUi } from './ui'
21+
22+
const CENTER = Vector3.create(8, 1, 8)
23+
const RING_RADIUS = 6
24+
const RING_BOX_COUNT = 10
25+
// Degrees of camera rotation per pixel of PrimaryPointerInfo.screenDelta
26+
const SENSITIVITY = 0.15
27+
28+
let cameraEntity: Entity
29+
let cameraActive = false
30+
let yaw = 0
31+
let pitch = 0
32+
33+
// Live values consumed by the HUD in ui.tsx. Updated from mouseLookSystem using getOrNull reads only,
34+
// so the HUD never triggers a CRDT re-sync by mutating components on every frame.
35+
export const state = {
36+
screenDelta: { x: 0, y: 0 },
37+
isPointerLocked: false,
38+
cameraActive: false,
39+
yaw: 0,
40+
pitch: 0
41+
}
42+
43+
export function main() {
44+
createGround()
45+
createReferenceRing()
46+
cameraEntity = createVirtualCamera()
47+
createToggleBox()
48+
49+
engine.addSystem(mouseLookSystem)
50+
engine.addSystem(exitSystem)
51+
52+
setupUi()
53+
}
54+
55+
function createGround() {
56+
const ground = engine.addEntity()
57+
Transform.create(ground, {
58+
position: Vector3.create(8, 0.01, 8),
59+
rotation: Quaternion.fromEulerDegrees(90, 0, 0),
60+
scale: Vector3.create(16, 16, 0.1)
61+
})
62+
MeshRenderer.setPlane(ground)
63+
Material.setBasicMaterial(ground, { diffuseColor: Color4.Gray() })
64+
}
65+
66+
// A ring of colored boxes at varied heights plus cardinal N/E/S/W text markers, so that camera
67+
// rotation driven by mouse-look is easy to read unambiguously.
68+
function createReferenceRing() {
69+
for (let i = 0; i < RING_BOX_COUNT; i++) {
70+
const angle = (i / RING_BOX_COUNT) * Math.PI * 2
71+
const x = CENTER.x + Math.cos(angle) * RING_RADIUS
72+
const z = CENTER.z + Math.sin(angle) * RING_RADIUS
73+
const height = 1 + (i % 4) * 0.75
74+
75+
const box = engine.addEntity()
76+
Transform.create(box, {
77+
position: Vector3.create(x, height / 2, z),
78+
scale: Vector3.create(0.8, height, 0.8)
79+
})
80+
MeshRenderer.setBox(box)
81+
MeshCollider.setBox(box)
82+
Material.setPbrMaterial(box, {
83+
albedoColor: Color4.create((i * 0.37) % 1, (i * 0.61) % 1, (i * 0.83) % 1, 1)
84+
})
85+
}
86+
87+
createCardinalMarker('N', Vector3.create(CENTER.x, 3, CENTER.z - RING_RADIUS - 1), 180)
88+
createCardinalMarker('S', Vector3.create(CENTER.x, 3, CENTER.z + RING_RADIUS + 1), 0)
89+
createCardinalMarker('E', Vector3.create(CENTER.x + RING_RADIUS + 1, 3, CENTER.z), 270)
90+
createCardinalMarker('W', Vector3.create(CENTER.x - RING_RADIUS - 1, 3, CENTER.z), 90)
91+
}
92+
93+
function createCardinalMarker(label: string, position: Vector3, facingYaw: number) {
94+
const marker = engine.addEntity()
95+
Transform.create(marker, {
96+
position,
97+
rotation: Quaternion.fromEulerDegrees(0, facingYaw, 0)
98+
})
99+
TextShape.create(marker, {
100+
text: label,
101+
fontSize: 6,
102+
textColor: Color4.White()
103+
})
104+
}
105+
106+
function createVirtualCamera(): Entity {
107+
const cam = engine.addEntity()
108+
Transform.create(cam, {
109+
position: Vector3.create(CENTER.x, 3, CENTER.z)
110+
})
111+
VirtualCamera.create(cam, {
112+
defaultTransition: { transitionMode: VirtualCamera.Transition.Time(0.5) }
113+
})
114+
return cam
115+
}
116+
117+
function createToggleBox() {
118+
const box = engine.addEntity()
119+
Transform.create(box, {
120+
position: Vector3.create(8, 1, 4)
121+
})
122+
MeshRenderer.setBox(box)
123+
MeshCollider.setBox(box)
124+
Material.setBasicMaterial(box, { diffuseColor: Color4.Green() })
125+
126+
pointerEventsSystem.onPointerDown(
127+
{
128+
entity: box,
129+
opts: { button: InputAction.IA_POINTER, hoverText: 'Toggle mouse-look camera' }
130+
},
131+
() => activateCamera(!cameraActive)
132+
)
133+
}
134+
135+
export function activateCamera(active: boolean) {
136+
cameraActive = active
137+
state.cameraActive = active
138+
139+
MainCamera.createOrReplace(engine.CameraEntity, {
140+
virtualCameraEntity: active ? cameraEntity : undefined
141+
})
142+
143+
InputModifier.createOrReplace(engine.PlayerEntity, {
144+
mode: InputModifier.Mode.Standard({ disableAll: active })
145+
})
146+
}
147+
148+
// THE SHOWCASE: PrimaryPointerInfo.screenDelta keeps reporting raw mouse delta while the pointer is
149+
// locked (renderer-side fix, shipped separately from this scene). We use it to drive virtual camera
150+
// rotation instead of the frozen screenCoordinates/worldRayDirection, which report the screen center
151+
// and center ray while locked.
152+
function mouseLookSystem() {
153+
const pointerInfo = PrimaryPointerInfo.getOrNull(engine.RootEntity)
154+
const pointerLock = PointerLock.getOrNull(engine.CameraEntity)
155+
156+
const dx = pointerInfo?.screenDelta?.x ?? 0
157+
const dy = pointerInfo?.screenDelta?.y ?? 0
158+
const isLocked = pointerLock?.isPointerLocked ?? false
159+
160+
state.screenDelta.x = dx
161+
state.screenDelta.y = dy
162+
state.isPointerLocked = isLocked
163+
164+
if (!cameraActive || !isLocked) return
165+
166+
yaw += dx * SENSITIVITY
167+
// NOTE: verify pitch sign in-engine — mouse up should look up, so a negative screenDelta.y
168+
// (cursor moving toward the top of the screen) must increase pitch.
169+
pitch = clamp(pitch - dy * SENSITIVITY, -85, 85)
170+
171+
state.yaw = yaw
172+
state.pitch = pitch
173+
174+
Transform.getMutable(cameraEntity).rotation = Quaternion.fromEulerDegrees(pitch, yaw, 0)
175+
}
176+
177+
function exitSystem() {
178+
if (!cameraActive) return
179+
if (inputSystem.isTriggered(InputAction.IA_SECONDARY, PointerEventType.PET_DOWN)) {
180+
activateCamera(false)
181+
}
182+
}
183+
184+
function clamp(value: number, min: number, max: number): number {
185+
return Math.min(max, Math.max(min, value))
186+
}

0 commit comments

Comments
 (0)