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