Skip to content
Source
Sign inSign up

phoneScene.ts

297 lines8.9 KB

1import {
2 BoxGeometry,
3 CircleGeometry,
4 CylinderGeometry,
5 ExtrudeGeometry,
6 Float32BufferAttribute,
7 Group,
8 Mesh,
9 MeshBasicMaterial,
10 MeshPhysicalMaterial,
11 MeshStandardMaterial,
12 Plane,
13 Raycaster,
14 Shape,
15 ShapeGeometry,
16 Vector2,
17 Vector3,
18 type Camera,
19 type BufferGeometry,
20 type Texture,
21} from "three";
22import type { DeviceScreenSize } from "./stream.ts";
23import { IOS_PHONE_SHAPE, type DeviceShapeProfile } from "./shapeProfile.ts";
24​
25const SCREEN_HEIGHT = 2.2;
26​
27function roundedPath(width: number, height: number, radius: number, path = new Shape()) {
28 const x = -width / 2;
29 const y = -height / 2;
30 path.moveTo(x + radius, y);
31 path.lineTo(x + width - radius, y);
32 path.quadraticCurveTo(x + width, y, x + width, y + radius);
33 path.lineTo(x + width, y + height - radius);
34 path.quadraticCurveTo(x + width, y + height, x + width - radius, y + height);
35 path.lineTo(x + radius, y + height);
36 path.quadraticCurveTo(x, y + height, x, y + height - radius);
37 path.lineTo(x, y + radius);
38 path.quadraticCurveTo(x, y, x + radius, y);
39 return path;
41​
42export function phoneDisplayLayout(
43 screen: DeviceScreenSize | null,
44 rawWidth: number,
45 rawHeight: number,
46) {
47 const width = rawWidth || screen?.width || 900;
48 const height = rawHeight || screen?.height || 1950;
49 const landscape =
50 screen?.orientation === "landscape_left" || screen?.orientation === "landscape_right";
51 const rotation =
52 screen?.orientation === "landscape_left"
53 ? -Math.PI / 2
54 : screen?.orientation === "landscape_right"
55 ? Math.PI / 2
56 : screen?.orientation === "portrait_upside_down"
57 ? Math.PI
58 : 0;
59 return {
60 aspect: Math.min(width, height) / Math.max(width, height),
61 rotation,
62 landscape,
63 rawLandscape: width > height,
64 };
66​
67export function createPhoneScene(
68 texture: Texture,
69 layout: ReturnType<typeof phoneDisplayLayout>,
70 profile: DeviceShapeProfile = IOS_PHONE_SHAPE,
71) {
72 const { aspect } = layout;
73 const root = new Group();
74 const orientation = new Group();
75 root.add(orientation);
76 const screenWidth = SCREEN_HEIGHT * aspect;
77 const width = screenWidth + profile.bezel * 2;
78 const height = SCREEN_HEIGHT + profile.bezel * 2;
79 const backZ = 0.01 - profile.depth;
80 const metal = new MeshStandardMaterial({ color: 0xb5bcc7, metalness: 0.88, roughness: 0.27 });
81 const glass = new MeshPhysicalMaterial({
82 color: 0x141820,
83 metalness: 0.15,
84 roughness: 0.2,
85 clearcoat: 1,
86 });
87 const backMaterial = new MeshStandardMaterial({
88 color: profile.backColor,
89 metalness: 0.45,
90 roughness: 0.32,
91 });
92 const lensMaterial = new MeshPhysicalMaterial({
93 color: 0x071326,
94 metalness: 0.6,
95 roughness: 0.12,
96 clearcoat: 1,
97 });
98 const materials = [metal, glass, backMaterial, lensMaterial];
99​
100 const body = new Mesh(
101 new ExtrudeGeometry(roundedPath(width, height, profile.bodyRadius), {
102 depth: profile.depth,
103 bevelEnabled: true,
104 bevelSize: 0.012,
105 bevelThickness: 0.012,
106 bevelSegments: 3,
107 steps: 1,
108 curveSegments: 12,
109 }),
110 metal,
111 );
112 body.position.z = 0.025 - profile.depth;
113 orientation.add(body);
114 const face = new Mesh(
115 new ShapeGeometry(roundedPath(width - 0.014, height - 0.014, profile.bodyRadius - 0.01), 16),
116 glass,
117 );
118 face.position.z = 0.04;
119 orientation.add(face);
120 const back = new Mesh(
121 new ShapeGeometry(roundedPath(width - 0.012, height - 0.012, profile.bodyRadius - 0.01), 16),
122 backMaterial,
123 );
124 back.name = "device-back";
125 back.rotation.y = Math.PI;
126 back.position.z = backZ;
127 orientation.add(back);
128​
129 const screenGeometry = new ShapeGeometry(
130 roundedPath(screenWidth, SCREEN_HEIGHT, profile.screenRadius),
131 20,
132 );
133 updateDisplayUv(screenGeometry, screenWidth, SCREEN_HEIGHT, layout);
134 const screenMaterial = new MeshBasicMaterial({ map: texture, toneMapped: false });
135 const display = new Mesh(screenGeometry, screenMaterial);
136 display.position.z = 0.043;
137 orientation.add(display);
138​
139 for (const { edge, offset, length } of profile.buttons) {
140 const button = new Mesh(
141 new BoxGeometry(
142 edge === "top" ? length : 0.026,
143 edge === "top" ? 0.026 : length,
144 profile.depth * 0.65,
145 ),
146 metal,
147 );
148 button.position.set(
149 edge === "top" ? offset : (edge === "left" ? -1 : 1) * (width / 2 + 0.015),
150 edge === "top" ? height / 2 + 0.015 : offset,
151 (0.025 + backZ) / 2,
152 );
153 orientation.add(button);
154 }
155 const rearCamera = new Group();
156 rearCamera.name = "rear-camera";
157 rearCamera.position.set(
158 width / 2 - profile.camera.insetX,
159 height / 2 - profile.camera.insetY,
160 back.position.z + 0.001,
161 );
162 rearCamera.rotation.y = Math.PI;
163 orientation.add(rearCamera);
164 const plateFront = 0.025 + 0.007;
165 const ringDepth = 0.025;
166 const cameraPlate = new Mesh(
167 new ExtrudeGeometry(
168 roundedPath(
169 profile.camera.width,
170 profile.camera.height,
171 Math.min(profile.camera.width, profile.camera.height) / 4,
172 ),
173 {
174 depth: 0.025,
175 bevelEnabled: true,
176 bevelSize: 0.009,
177 bevelThickness: 0.007,
178 bevelSegments: 2,
179 },
180 ),
181 backMaterial,
182 );
183 cameraPlate.name = "camera-plate";
184 rearCamera.add(cameraPlate);
185 for (const [x, y] of profile.camera.lenses) {
186 const radius = profile.camera.lensRadius;
187 const ring = new Mesh(new CylinderGeometry(radius + 0.014, radius + 0.014, 0.025, 32), metal);
188 ring.rotation.x = Math.PI / 2;
189 ring.name = "camera-ring";
190 ring.position.set(x, y, plateFront + ringDepth / 2 - 0.003);
191 rearCamera.add(ring);
192 const lens = new Mesh(new CircleGeometry(radius, 32), lensMaterial);
193 lens.name = "camera-lens";
194 lens.position.set(x, y, ring.position.z + ringDepth / 2 + 0.0005);
195 rearCamera.add(lens);
196 }
197 const flashMaterial = new MeshBasicMaterial({ color: 0xf2ead6 });
198 if (profile.camera.flash) {
199 const flash = new Mesh(new CircleGeometry(0.022, 20), flashMaterial);
200 flash.position.set(profile.camera.flash[0], profile.camera.flash[1], plateFront + 0.0005);
201 rearCamera.add(flash);
202 }
203​
204 let activeLayout = layout;
205 const screenPoint = createDisplayProjection(
206 display,
207 orientation,
208 screenWidth,
209 SCREEN_HEIGHT,
210 () => activeLayout,
211 );
212 return {
213 root,
214 orientation,
215 width,
216 height,
217 screenPoint,
218 setDisplay(nextTexture: Texture, nextLayout: PhoneDisplayLayout) {
219 activeLayout = nextLayout;
220 screenMaterial.map = nextTexture;
221 updateDisplayUv(screenGeometry, screenWidth, SCREEN_HEIGHT, activeLayout);
222 },
223 dispose() {
224 root.traverse((object) => {
225 if (object instanceof Mesh) object.geometry.dispose();
226 });
227 for (const material of materials) material.dispose();
228 screenMaterial.dispose();
229 flashMaterial.dispose();
230 },
231 };
233​
234export type PhoneDisplayLayout = ReturnType<typeof phoneDisplayLayout>;
235​
236export function updateDisplayUv(
237 geometry: BufferGeometry,
238 width: number,
239 height: number,
240 layout: PhoneDisplayLayout,
241) {
242 const position = geometry.getAttribute("position");
243 if (!geometry.hasAttribute("uv")) {
244 geometry.setAttribute(
245 "uv",
246 new Float32BufferAttribute(new Float32Array(position.count * 2), 2),
247 );
248 }
249 const uv = geometry.getAttribute("uv");
250 for (let i = 0; i < position.count; i++) {
251 const u = (position.getX(i) + width / 2) / width;
252 const v = (position.getY(i) + height / 2) / height;
253 uv.setXY(
254 i,
255 layout.rawLandscape ? (layout.rotation > 0 ? 1 - v : v) : u,
256 layout.rawLandscape ? (layout.rotation > 0 ? u : 1 - u) : v,
257 );
258 }
259 uv.needsUpdate = true;
261​
262export function createDisplayProjection(
263 display: Mesh,
264 orientation: Group,
265 width: number,
266 height: number,
267 getLayout: () => PhoneDisplayLayout,
268) {
269 const raycaster = new Raycaster();
270 const pointer = new Vector2();
271 const local = new Vector3();
272 display.geometry.computeBoundingBox();
273 const z = display.position.z + (display.geometry.boundingBox?.max.z ?? 0);
274 const plane = new Plane(new Vector3(0, 0, 1), -z);
275 return (x: number, y: number, camera: Camera, captured = false) => {
276 orientation.updateWorldMatrix(true, true);
277 camera.updateMatrixWorld(true);
278 pointer.set(x * 2 - 1, 1 - y * 2);
279 raycaster.setFromCamera(pointer, camera);
280 if (!captured) {
281 const hit = raycaster.intersectObject(display, false)[0];
282 if (!hit) return null;
283 local.copy(hit.point);
284 orientation.worldToLocal(local);
285 } else {
286 const ray = raycaster.ray.clone().applyMatrix4(orientation.matrixWorld.clone().invert());
287 if (!ray.intersectPlane(plane, local)) return null;
288 }
289 const u = Math.min(1, Math.max(0, (local.x + width / 2) / width));
290 const v = Math.min(1, Math.max(0, (local.y + height / 2) / height));
291 const { rotation } = getLayout();
292 if (rotation === -Math.PI / 2) return { x: v, y: u };
293 if (rotation === Math.PI / 2) return { x: 1 - v, y: 1 - u };
294 if (rotation === Math.PI) return { x: 1 - u, y: v };
295 return { x: u, y: 1 - v };
296 };