Spaces:
Paused
Paused
Download src/web/js/renderer3d.js from deepondas/laya-snake: direct link, hf CLI and curl.
- Browser
- Download file 12.3 kB
-
https://huggingface.co/spaces/deepondas/laya-snake/resolve/main/src/web/js/renderer3d.js
- Command line
-
hf download hf://spaces/deepondas/laya-snake/src/web/js/renderer3d.js
-
curl -L -o renderer3d.js https://huggingface.co/spaces/deepondas/laya-snake/resolve/main/src/web/js/renderer3d.js
12.3 kB
| import * as THREE from "three"; | |
| import { Garden, MARGIN } from "./scene3d.js"; | |
| import { Snake3D } from "./snake3d.js"; | |
| import { Rabbits } from "./rabbit3d.js"; | |
| import { Centerline, buildCenterline } from "./snakeMotion.js"; | |
| // Camera presets: the 3D view shows the whole yard from high up; Realistic is lower, angled and closer. | |
| const MODES = { | |
| "3d": { fov: 36, pitch: 55, yaw: 0, fit: 1, follow: 13 }, | |
| cinematic: { fov: 30, pitch: 38, yaw: -16, fit: 0.84, follow: 10 }, | |
| }; | |
| const UP = new THREE.Vector3(0, 1, 0); | |
| const _dir = new THREE.Vector3(), _pos = new THREE.Vector3(), _target = new THREE.Vector3(), _mat = new THREE.Matrix4(), _q = new THREE.Quaternion(); | |
| // Miniature-style depth of field: keeps a horizontal band around the snake sharp, blurs above and | |
| // below, adds a light vignette, then tone-maps to the screen. | |
| class TiltShift { | |
| constructor() { | |
| this.target = new THREE.WebGLRenderTarget(1, 1, { type: THREE.HalfFloatType, samples: 4 }); | |
| this.material = new THREE.ShaderMaterial({ | |
| uniforms: { tDiffuse: { value: this.target.texture }, resolution: { value: new THREE.Vector2(1, 1) }, focusY: { value: 0.5 }, band: { value: 0.2 }, maxBlur: { value: 5 } }, | |
| vertexShader: "varying vec2 vUv; void main() { vUv = uv; gl_Position = vec4(position.xy, 0.0, 1.0); }", | |
| fragmentShader: ` | |
| uniform sampler2D tDiffuse; uniform vec2 resolution; uniform float focusY; uniform float band; uniform float maxBlur; | |
| varying vec2 vUv; | |
| const vec2 taps[12] = vec2[](vec2(-0.326,-0.406), vec2(-0.840,-0.074), vec2(-0.696,0.457), vec2(-0.203,0.621), | |
| vec2(0.962,-0.195), vec2(0.473,-0.480), vec2(0.519,0.767), vec2(0.185,-0.893), vec2(0.507,0.064), | |
| vec2(0.896,0.412), vec2(-0.322,-0.933), vec2(-0.792,-0.598)); | |
| void main() { | |
| float blur = smoothstep(band, band + 0.32, abs(vUv.y - focusY)) * maxBlur; | |
| vec4 c = texture2D(tDiffuse, vUv); | |
| if (blur > 0.25) { | |
| vec4 acc = c; | |
| for (int i = 0; i < 12; i++) acc += texture2D(tDiffuse, vUv + taps[i] * blur / resolution); | |
| c = acc / 13.0; | |
| } | |
| vec2 q = vUv - 0.5; | |
| c.rgb *= 1.0 - dot(q, q) * 0.6; | |
| gl_FragColor = c; | |
| #include <tonemapping_fragment> | |
| #include <colorspace_fragment> | |
| }`, | |
| depthTest: false, depthWrite: false, | |
| }); | |
| this.quad = new THREE.Mesh(new THREE.PlaneGeometry(2, 2), this.material); | |
| this.quad.frustumCulled = false; | |
| this.scene = new THREE.Scene(); | |
| this.scene.add(this.quad); | |
| this.camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1); | |
| } | |
| setSize(w, h) { | |
| this.target.setSize(w, h); | |
| this.material.uniforms.resolution.value.set(w, h); | |
| this.material.uniforms.maxBlur.value = Math.max(2, h / 320); | |
| } | |
| render(renderer, scene, camera, focusY) { | |
| renderer.setRenderTarget(this.target); | |
| renderer.render(scene, camera); | |
| renderer.setRenderTarget(null); | |
| this.material.uniforms.focusY.value = focusY; | |
| renderer.render(this.scene, this.camera); | |
| } | |
| dispose() { this.target.dispose(); this.material.dispose(); this.quad.geometry.dispose(); } | |
| } | |
| // The 3D game world. It only reads the server state it is given; it never touches the dashboard. | |
| export class ThreeRenderer { | |
| constructor(canvas, prefs) { | |
| this.canvas = canvas; | |
| this.prefs = prefs; | |
| this.ready = false; | |
| this.mode = "3d"; | |
| this.S = null; | |
| this.cl = new Centerline(); | |
| this.look = new THREE.Vector3(); | |
| this.camSnap = true; | |
| this.last = 0; | |
| this.debug = null; | |
| } | |
| // Creates the renderer, scene, lights and models (once). | |
| async init() { | |
| const r = new THREE.WebGLRenderer({ canvas: this.canvas, antialias: true, powerPreference: "high-performance" }); | |
| this.renderer = r; | |
| r.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2)); | |
| r.outputColorSpace = THREE.SRGBColorSpace; | |
| r.toneMapping = THREE.ACESFilmicToneMapping; | |
| r.shadowMap.enabled = true; | |
| r.shadowMap.type = THREE.PCFSoftShadowMap; | |
| this.scene = new THREE.Scene(); | |
| this.camera = new THREE.PerspectiveCamera(MODES["3d"].fov, 1, 0.1, 400); | |
| this.garden = new Garden(this.scene, r); | |
| this.snake = new Snake3D(this.scene); | |
| this.rabbits = new Rabbits(this.scene); | |
| this.post = new TiltShift(); | |
| await Promise.all([this.snake.load(), this.rabbits.load()]); | |
| this.ready = true; | |
| this.resize(); | |
| if (this.S) { const s = this.S; this.S = null; this.update(s); } | |
| } | |
| // Switches between the "3d" overview and the "cinematic" (Realistic) presentation. | |
| setMode(mode) { this.mode = MODES[mode] ? mode : "3d"; } | |
| // Takes a new server snapshot: board size, rocks and food positions come straight from it. | |
| update(s) { | |
| const resized = !this.S || this.S.width !== s.width || this.S.height !== s.height; | |
| this.S = s; | |
| if (!this.ready) return; | |
| if (resized) { | |
| this.garden.build(s.width, s.height); | |
| this.rabbits.clear(); | |
| this.camSnap = true; | |
| if (this.debug) this.debug.gridKey = ""; | |
| } | |
| this.garden.setObstacles(s.obstacles); | |
| this.rabbits.setFoods(s.foods, s.width, s.height, performance.now()); | |
| } | |
| // Draws one frame: interpolate the snake, animate, move the camera, render. | |
| render(now) { | |
| if (!this.ready || !this.S) return; | |
| const dt = this.last ? Math.min(100, now - this.last) : 16; | |
| this.last = now; | |
| const s = this.S, cinematic = this.mode === "cinematic"; | |
| this.garden.setLighting(this.prefs.tod, cinematic); | |
| this.garden.setGrid(this.prefs.grid); | |
| this.garden.update(now); | |
| buildCenterline(s, now, this.cl); | |
| this.snake.update(this.cl, s, now, dt); | |
| this.rabbits.showZ = !cinematic; | |
| this.rabbits.update(now, dt); | |
| this.updateCamera(dt); | |
| if (this.debug) this.updateDebug(dt); | |
| if (cinematic) { | |
| _pos.set(this.cl.n ? this.cl.x[0] - s.width / 2 : 0, 0.2, this.cl.n ? this.cl.y[0] - s.height / 2 : 0).project(this.camera); | |
| this.post.render(this.renderer, this.scene, this.camera, Math.min(0.85, Math.max(0.15, _pos.y * 0.5 + 0.5))); | |
| } else { | |
| this.renderer.render(this.scene, this.camera); | |
| } | |
| } | |
| // Camera distance that fits the fenced yard in view at this pitch, yaw and aspect. | |
| fitDistance(W, H, M) { | |
| const pitch = THREE.MathUtils.degToRad(M.pitch), yaw = Math.abs(THREE.MathUtils.degToRad(M.yaw)); | |
| const hw = W / 2 + MARGIN * 0.55, hd = H / 2 + MARGIN * 0.55; | |
| const tv = Math.tan(THREE.MathUtils.degToRad(this.camera.fov) / 2), th = tv * this.camera.aspect; | |
| const across = hw * Math.cos(yaw) + hd * Math.sin(yaw), deep = hd * Math.cos(yaw) + hw * Math.sin(yaw); | |
| const distV = (deep * Math.sin(pitch) + 0.9 * Math.cos(pitch)) / tv + deep * Math.cos(pitch); | |
| const distH = across / th + deep * Math.cos(pitch) * 0.6; | |
| return Math.max(distV, distH); | |
| } | |
| // Eases the camera toward the overview or toward the snake's head; never snaps except on a new board. | |
| updateCamera(dt) { | |
| if (this.debug && this.debug.orbit && this.debug.orbit.enabled) { this.debug.orbit.update(); return; } | |
| const s = this.S, M = MODES[this.mode], W = s.width, H = s.height, cam = this.camera; | |
| const a = this.camSnap ? 1 : 1 - Math.exp(-dt / 450); | |
| if (Math.abs(cam.fov - M.fov) > 0.01) { cam.fov += (M.fov - cam.fov) * a; cam.updateProjectionMatrix(); } | |
| const pitch = THREE.MathUtils.degToRad(M.pitch), yaw = THREE.MathUtils.degToRad(M.yaw); | |
| _dir.set(Math.sin(yaw) * Math.cos(pitch), Math.sin(pitch), Math.cos(yaw) * Math.cos(pitch)); | |
| const fit = this.fitDistance(W, H, M); | |
| let dist = fit * M.fit; | |
| _target.set(0, 0, 0); | |
| if (this.prefs.follow && this.cl.n) { | |
| const reachX = Math.max(0, W / 2 - 1.5), reachZ = Math.max(0, H / 2 - 1); | |
| _target.set(THREE.MathUtils.clamp(this.cl.x[0] - W / 2, -reachX, reachX), 0, THREE.MathUtils.clamp(this.cl.y[0] - H / 2, -reachZ, reachZ)); | |
| dist = Math.min(fit, M.follow); | |
| } | |
| if (s.game_over) dist *= 0.93; | |
| _pos.copy(_target).addScaledVector(_dir, dist); | |
| cam.position.lerp(_pos, a); | |
| this.look.lerp(_target, a); | |
| _mat.lookAt(cam.position, this.look, UP); | |
| _q.setFromRotationMatrix(_mat); | |
| if (this.camSnap) cam.quaternion.copy(_q); else cam.quaternion.slerp(_q, 1 - Math.exp(-dt / 160)); | |
| this.camSnap = false; | |
| } | |
| // Keeps the drawing buffer, camera aspect and post-processing target in step with the canvas size. | |
| resize() { | |
| if (!this.renderer) return; | |
| const w = Math.max(1, this.canvas.clientWidth), h = Math.max(1, this.canvas.clientHeight); | |
| const pr = Math.min(window.devicePixelRatio || 1, 2); | |
| this.renderer.setPixelRatio(pr); | |
| this.renderer.setSize(w, h, false); | |
| this.camera.aspect = w / h; | |
| this.camera.updateProjectionMatrix(); | |
| this.post.setSize(Math.round(w * pr), Math.round(h * pr)); | |
| } | |
| // Called when the 2D view takes over; the next frame eases back in from where the camera was. | |
| deactivate() { this.last = 0; } | |
| // Turns on the developer overlay: path, skeleton, grid, axes, orbit camera, FPS. | |
| async enableDebug(panel) { | |
| const d = this.debug = { panel, fps: 60, gridKey: "", flags: {}, orbit: null }; | |
| const pathGeo = new THREE.BufferGeometry(); | |
| pathGeo.setAttribute("position", new THREE.BufferAttribute(new Float32Array(8192 * 3), 3)); | |
| d.path = new THREE.Line(pathGeo, new THREE.LineBasicMaterial({ color: 0xffd23f, depthTest: false })); | |
| d.path.renderOrder = 10; d.path.frustumCulled = false; | |
| d.axes = new THREE.AxesHelper(3); | |
| d.grid = new THREE.LineSegments(new THREE.BufferGeometry(), new THREE.LineBasicMaterial({ color: 0x4ef296, transparent: true, opacity: 0.6 })); | |
| for (const o of [d.path, d.axes, d.grid]) { o.visible = false; this.scene.add(o); } | |
| const { OrbitControls } = await import("three/addons/controls/OrbitControls.js"); | |
| d.orbit = new OrbitControls(this.camera, this.canvas); | |
| d.orbit.enabled = false; | |
| panel.querySelectorAll("input[data-dbg]").forEach((box) => { | |
| box.onchange = () => { | |
| d.flags[box.dataset.dbg] = box.checked; | |
| if (box.dataset.dbg === "orbit") { d.orbit.enabled = box.checked; d.orbit.target.copy(this.look); if (!box.checked) this.camSnap = false; } | |
| }; | |
| }); | |
| } | |
| // Refreshes the debug helpers and readouts. | |
| updateDebug(dt) { | |
| const d = this.debug, s = this.S, W = s.width, H = s.height; | |
| d.fps += (1000 / Math.max(1, dt) - d.fps) * 0.05; | |
| d.path.visible = !!d.flags.path; | |
| if (d.path.visible) { | |
| const a = d.path.geometry.attributes.position, n = Math.min(this.cl.n, 8192); | |
| for (let i = 0; i < n; i++) a.setXYZ(i, this.cl.x[i] - W / 2, 0.05, this.cl.y[i] - H / 2); | |
| a.needsUpdate = true; | |
| d.path.geometry.setDrawRange(0, n); | |
| } | |
| d.axes.visible = !!d.flags.axes; | |
| d.grid.visible = !!d.flags.grid; | |
| if (d.grid.visible && d.gridKey !== W + "x" + H) { | |
| d.gridKey = W + "x" + H; | |
| const pts = []; | |
| for (let i = 0; i <= W; i++) pts.push(i - W / 2, 0.02, -H / 2, i - W / 2, 0.02, H / 2); | |
| for (let j = 0; j <= H; j++) pts.push(-W / 2, 0.02, j - H / 2, W / 2, 0.02, j - H / 2); | |
| d.grid.geometry.dispose(); | |
| d.grid.geometry = new THREE.BufferGeometry().setAttribute("position", new THREE.Float32BufferAttribute(pts, 3)); | |
| } | |
| const root = this.snake.rig && this.snake.rig.root; | |
| if (d.flags.bones && root && (!d.bones || d.bonesRoot !== root)) { | |
| if (d.bones) { this.scene.remove(d.bones); d.bones.dispose(); } | |
| d.bones = new THREE.SkeletonHelper(root); | |
| d.bones.material.depthTest = false; | |
| d.bonesRoot = root; | |
| this.scene.add(d.bones); | |
| } | |
| if (d.bones) d.bones.visible = !!d.flags.bones; | |
| const q = (id, v) => { const el = d.panel.querySelector("#" + id); if (el && el.textContent !== v) el.textContent = v; }; | |
| q("dbg-fps", d.fps.toFixed(0)); | |
| const c = this.camera.position; | |
| q("dbg-cam", `${c.x.toFixed(1)}, ${c.y.toFixed(1)}, ${c.z.toFixed(1)}`); | |
| q("dbg-model", `${this.snake.describe()} · rabbit ${this.rabbits.kind}`); | |
| } | |
| // Releases every GPU resource this renderer created. | |
| destroy() { | |
| if (!this.renderer) return; | |
| this.snake.dispose(); | |
| this.rabbits.dispose(); | |
| this.garden.dispose(); | |
| this.post.dispose(); | |
| if (this.debug && this.debug.orbit) this.debug.orbit.dispose(); | |
| this.renderer.dispose(); | |
| this.ready = false; | |
| } | |
| } | |