Download damage.js from SharpMeow/unroll: direct link, hf CLI and curl.
- Browser
- Download file 36 kB
-
https://huggingface.co/spaces/SharpMeow/unroll/resolve/main/damage.js
- Command line
-
hf download hf://spaces/SharpMeow/unroll/damage.js
-
curl -L -o damage.js https://huggingface.co/spaces/SharpMeow/unroll/resolve/main/damage.js
36 kB
| // Destroy mode: damage painted into a high-resolution mask in each winding's UV space, so holes, cracks, embers | |
| // and wet patches follow the surface as it rolls and unrolls. The visual edge is an alpha test in the fragment | |
| // shader against that smooth field plus fiber-direction noise (no per-triangle removal, so no staircase). | |
| // Channels: R missing (crushed, torn, burnt), G cracks, B ember heat, A wetness. | |
| // Particles (dust, fragments, flakes, smoke, ash, drops, puddles) live in their own capped system. | |
| export const DMG_HEAD = /* glsl */` | |
| uniform sampler2D uDmg; uniform float uDmgOn; uniform vec2 uDmgMM; uniform float uDmgT; | |
| uniform float uCrumble; // Crumble: front from the sheet edges inward, 0 none, past 1.15 all gone | |
| float dHash(vec2 p) { return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); } | |
| float dNoise(vec2 p) { | |
| vec2 i = floor(p), f = fract(p); f = f * f * (3.0 - 2.0 * f); | |
| return mix(mix(dHash(i), dHash(i + vec2(1.0, 0.0)), f.x), mix(dHash(i + vec2(0.0, 1.0)), dHash(i + vec2(1.0, 1.0)), f.x), f.y); | |
| } | |
| // papyrus fibers run along the sheet: noise stretched along x, fine across y | |
| float dFib(vec2 mm) { | |
| vec2 p = mm * vec2(0.7, 6.5); | |
| return 0.5 * dNoise(p) + 0.3 * dNoise(p * 2.13 + 3.7) + 0.2 * dNoise(mm * 6.0 + 11.0); | |
| } | |
| // the damage field the alpha test uses: smooth mask, fiber noise, and a coarser ragged term for tufts and bites | |
| float dField(float r, float fib, vec2 mm) { | |
| float g = smoothstep(0.05, 0.35, r); // no noise far from damage, so no stray pinholes | |
| return r * 1.1 + ((fib - 0.5) * 0.7 + (dNoise(mm * vec2(0.9, 1.6) + 5.3) - 0.5) * 0.3) * g; | |
| } | |
| // how far into the sheet a point is, 0 at any edge and 1 at the middle, made ragged by the fibers | |
| float dCrumbleD(vec2 uv, vec2 mm) { | |
| float e = min(min(uv.x, 1.0 - uv.x) * mm.x, min(uv.y, 1.0 - uv.y) * mm.y) / (0.5 * min(mm.x, mm.y)); | |
| return clamp(e, 0.0, 1.0) + (dFib(uv * mm) - 0.5) * 0.22 + (dNoise(uv * mm * 0.35 + 9.1) - 0.5) * 0.18; | |
| } | |
| `; | |
| // after the surface normal is known: alpha test against the field | |
| export const DMG_CUT = /* glsl */` | |
| vec4 dd = vec4(0.0); float dTh = 0.0, dFibV = 0.5, dCr = 0.0; | |
| if (uCrumble > 0.0) { | |
| float cd = dCrumbleD(vLocal, uDmgMM) - uCrumble; | |
| if (cd < 0.0) discard; | |
| dCr = 1.0 - smoothstep(0.0, 0.07, cd); // a dark crumbling rim just ahead of the front | |
| } | |
| if (uDmgOn > 0.5) { | |
| dd = texture2D(uDmg, vLocal); | |
| dFibV = dFib(vLocal * uDmgMM); | |
| dTh = dd.r > 0.004 ? dField(dd.r, dFibV, vLocal * uDmgMM) : 0.0; | |
| if (dTh > 0.5) discard; | |
| } | |
| `; | |
| // before the final color: charred band, lifted lip and its shadow, cracks, wet gloss, embers | |
| export const DMG_EDGE = /* glsl */` | |
| if (dCr > 0.0) col = mix(col, vec3(0.03, 0.022, 0.016), dCr * 0.85); | |
| if (uDmgOn > 0.5) { | |
| float charK = smoothstep(0.16, 0.495, dTh); | |
| float lipK = smoothstep(0.08, 0.2, dTh) * (1.0 - charK); | |
| float shadow = smoothstep(0.38, 0.7, texture2D(uDmg, vLocal + vec2(-0.3, 0.45) / uDmgMM).r) * (1.0 - charK); | |
| col *= 1.0 + 0.32 * lipK; | |
| col *= 1.0 - 0.6 * shadow; | |
| col *= 1.0 - 0.45 * smoothstep(0.1, 0.5, dd.g); | |
| float crack = dd.g * smoothstep(0.35, 0.75, dFibV + dd.g * 0.35); | |
| col = mix(col, vec3(0.010, 0.007, 0.005) * (0.6 + 0.8 * dFibV), clamp(charK * 0.97 + crack * 0.9, 0.0, 1.0)); | |
| // wet charcoal: soaks up the water and goes matt black-brown, no sheen | |
| float wet = dd.a; | |
| col = mix(col, col * vec3(0.16, 0.13, 0.11), smoothstep(0.0, 0.7, wet)); | |
| // smoldering charcoal: a dim, slowly breathing deep orange in the char band, no flame | |
| float flick = 0.75 + 0.25 * sin(uDmgT * 0.9 + dFibV * 23.0) * sin(uDmgT * 0.43 + dFibV * 7.0); | |
| float emb = dd.b * flick; | |
| col += vec3(0.85, 0.22, 0.04) * emb * emb * (0.25 + 1.1 * charK) * 0.9; | |
| } | |
| `; | |
| export function injectFrag(frag, cutAfter) { | |
| if (!frag.includes(cutAfter)) throw new Error('damage: shader anchor missing'); | |
| return DMG_HEAD + frag.replace(cutAfter, cutAfter + DMG_CUT).replace('gl_FragColor = vec4(col, 1.0);', DMG_EDGE + ' gl_FragColor = vec4(col, 1.0);'); | |
| } | |
| export function createDamage({ THREE, scene, meshes, MOBILE, ground, uDmgT, uCrumble, surfacePoint }) { | |
| const RES = MOBILE ? 1024 : 2048; | |
| const masks = new Map(); | |
| const shared = { uDmgT: uDmgT || { value: 0 }, uCrumble: uCrumble || { value: 0 } }; | |
| const own = (m) => m.userData.own; | |
| function mask(m) { | |
| let k = masks.get(m); | |
| if (k) return k; | |
| const { w } = m.userData; | |
| const W = RES, H = Math.max(64, Math.round((RES * w.heightMM) / w.widthMM)); | |
| const data = new Uint8Array(W * H * 4); | |
| const tex = new THREE.DataTexture(data, W, H, THREE.RGBAFormat); | |
| tex.minFilter = tex.magFilter = THREE.LinearFilter; | |
| tex.generateMipmaps = false; tex.flipY = false; tex.needsUpdate = true; | |
| k = { m, W, H, data, tex, pxmm: W / w.widthMM, dirty: true, embers: [], wet: [], world: null }; | |
| masks.set(m, k); | |
| own(m).uDmg.value = tex; own(m).uDmgOn.value = 1; | |
| return k; | |
| } | |
| const at = (k, u, v, c) => { | |
| const x = Math.min(k.W - 1, Math.max(0, Math.round(u * k.W))), y = Math.min(k.H - 1, Math.max(0, Math.round(v * k.H))); | |
| return k.data[(y * k.W + x) * 4 + c] / 255; | |
| }; | |
| // a soft elliptical brush, wider along the fibers; amount adds to one channel | |
| function blob(k, u, v, rMM, c, amount, aspect = 1.45) { | |
| const cx = u * k.W, cy = v * k.H, rx = rMM * k.pxmm * aspect, ry = rMM * k.pxmm; | |
| const x0 = Math.max(0, Math.floor(cx - rx)), x1 = Math.min(k.W - 1, Math.ceil(cx + rx)); | |
| const y0 = Math.max(0, Math.floor(cy - ry)), y1 = Math.min(k.H - 1, Math.ceil(cy + ry)); | |
| const d = k.data, W = k.W, a = amount * 255; | |
| for (let y = y0; y <= y1; y++) { | |
| const dy = (y - cy) / ry, dy2 = dy * dy; | |
| for (let x = x0; x <= x1; x++) { | |
| const dx = (x - cx) / rx, q = dx * dx + dy2; | |
| if (q >= 1) continue; | |
| const f = (1 - q) * (1 - q); | |
| const i = (y * W + x) * 4 + c; | |
| d[i] = Math.min(255, d[i] + a * f); | |
| } | |
| } | |
| k.dirty = true; | |
| } | |
| function line(k, u0, v0, u1, v1, rMM, c, amount, wobble) { | |
| const mm = Math.hypot((u1 - u0) * k.W, (v1 - v0) * k.H) / k.pxmm; | |
| const n = Math.max(1, Math.ceil(mm / (rMM * 0.6))); | |
| const nx = -(v1 - v0) * k.H, ny = (u1 - u0) * k.W, nl = Math.hypot(nx, ny) || 1; | |
| for (let i = 0; i <= n; i++) { | |
| const f = i / n, j = wobble ? (Math.sin(f * 9.1 + u0 * 50) * 0.6 + (Math.random() - 0.5)) * wobble * k.pxmm : 0; | |
| blob(k, u0 + (u1 - u0) * f + (nx / nl) * j / k.W, v0 + (v1 - v0) * f + (ny / nl) * j / k.H, rMM, c, amount, 1.15); | |
| } | |
| } | |
| const mmToUV = (k, dx, dy) => [dx * k.pxmm / k.W, dy * k.pxmm / k.H]; | |
| // ------------------------------------------------------------ tools | |
| function crush(m, u, v, rMM, strength) { | |
| const k = mask(m); | |
| const wet = at(k, u, v, 3); | |
| const s = strength * (1 + 1.6 * wet); // soaked papyrus gives way | |
| blob(k, u, v, rMM, 0, 0.95 * s); | |
| for (let i = 0; i < 5; i++) { | |
| const a = Math.random() * 6.283, r = rMM * (0.5 + Math.random() * 0.6); | |
| const [du, dv] = mmToUV(k, Math.cos(a) * r, Math.sin(a) * r * 0.7); | |
| blob(k, u + du, v + dv, rMM * (0.35 + Math.random() * 0.35), 0, 0.7 * s); | |
| } | |
| // radial cracks into the surrounding sheet | |
| const nc = 2 + (Math.random() * 3 | 0); | |
| for (let i = 0; i < nc; i++) { | |
| const a = Math.random() * 6.283, l = rMM * (1.2 + Math.random() * 1.6); | |
| const [a0, b0] = mmToUV(k, Math.cos(a) * rMM * 0.7, Math.sin(a) * rMM * 0.7); | |
| const [a1, b1] = mmToUV(k, Math.cos(a) * l * 1.4, Math.sin(a) * l * 0.6); | |
| line(k, u + a0, v + b0, u + a1, v + b1, 0.22, 1, 0.9, 0.25); | |
| } | |
| } | |
| function bruise(m, u, v, rMM) { | |
| const k = mask(m); | |
| blob(k, u, v, rMM, 1, 0.22, 1.3); | |
| } | |
| function tear(m, u0, v0, u1, v1) { | |
| const k = mask(m); | |
| const wet = at(k, u1, v1, 3); | |
| line(k, u0, v0, u1, v1, 0.6 + wet * 0.5, 0, 0.9, 0.45); | |
| line(k, u0, v0, u1, v1, 1.1, 1, 0.35, 0.6); | |
| } | |
| function wetAt(m, u, v, rMM, amount) { | |
| const k = mask(m); | |
| blob(k, u, v, rMM, 3, amount, 1.25); | |
| k.wet.push({ u, v, r: rMM, t: 0 }); | |
| if (k.wet.length > 200) k.wet.shift(); | |
| // water puts embers out | |
| for (const e of k.embers) if (Math.hypot((e.u - u) * k.W, (e.v - v) * k.H) / k.pxmm < rMM * 1.3) e.life = 0; | |
| } | |
| let emberCount = 0; | |
| // where an ember is now: its UV mapped through the current roll onto the sheet, nudged off the surface along the | |
| // normal. Falls back to the ignition point only when no surface mapping is available. | |
| const sp = new THREE.Vector3(), sn = new THREE.Vector3(); | |
| let emberSpawns = 0, emberFallback = 0; | |
| function emberAt(k, e, lift) { | |
| if (surfacePoint && surfacePoint(k.m, e.u, e.v, sp, sn)) { | |
| if (sn.z < 0) sn.multiplyScalar(-1); // toward the open side the camera usually faces | |
| sp.addScaledVector(sn, lift); emberSpawns++; return true; | |
| } | |
| if (!k.world) return false; | |
| sp.copy(k.world); sp.z += lift; sn.set(0, 0, 1); emberFallback++; return true; | |
| } | |
| const EMAX = MOBILE ? 140 : 360; | |
| function ignite(m, u, v, world) { | |
| const k = mask(m); | |
| k.world = world.clone(); | |
| k.burnR = 0; | |
| for (let i = 0; i < 7; i++) { const a = i * 0.9, [du, dv] = mmToUV(k, Math.cos(a) * 0.8, Math.sin(a) * 0.5); addEmber(k, u + du, v + dv, 0); } | |
| } | |
| function addEmber(k, u, v, gen) { | |
| if (emberCount >= EMAX || u < 0 || v < 0 || u > 1 || v > 1) return; | |
| if (at(k, u, v, 3) > 0.35) return; // too wet to catch | |
| if (at(k, u, v, 0) > 0.6) return; // already gone | |
| k.embers.push({ u, v, gen, life: 4 + Math.random() * 4, age: 0, next: 0.3 + Math.random() * 0.6 }); | |
| emberCount++; | |
| } | |
| // ------------------------------------------------------------ particles | |
| const PMAX = MOBILE ? 700 : 1800; | |
| const pPos = new Float32Array(PMAX * 3), pVel = new Float32Array(PMAX * 3), pCol = new Float32Array(PMAX * 4), pSize = new Float32Array(PMAX); | |
| const pSleep = new Uint8Array(PMAX), pType = new Uint8Array(PMAX), pLife = new Float32Array(PMAX), pAge = new Float32Array(PMAX), pSeed = new Float32Array(PMAX); | |
| let pHead = 0; | |
| const pA0 = new Float32Array(PMAX); // spawn opacity: fades scale from it, so faint smoke stays faint | |
| const pGeo = new THREE.BufferGeometry(); | |
| pGeo.setAttribute('position', new THREE.BufferAttribute(pPos, 3).setUsage(THREE.DynamicDrawUsage)); | |
| pGeo.setAttribute('aCol', new THREE.BufferAttribute(pCol, 4).setUsage(THREE.DynamicDrawUsage)); | |
| pGeo.setAttribute('aSize', new THREE.BufferAttribute(pSize, 1).setUsage(THREE.DynamicDrawUsage)); | |
| const pMat = new THREE.ShaderMaterial({ | |
| transparent: true, depthWrite: false, | |
| uniforms: { uScale: { value: 400 } }, | |
| vertexShader: /* glsl */`attribute vec4 aCol; attribute float aSize; uniform float uScale; varying vec4 vC; | |
| void main() { vC = aCol; vec4 mv = modelViewMatrix * vec4(position, 1.0); gl_Position = projectionMatrix * mv; | |
| gl_PointSize = aCol.a <= 0.0 ? 0.0 : max(1.0, aSize * uScale / -mv.z); }`, | |
| fragmentShader: /* glsl */`varying vec4 vC; | |
| void main() { float d = length(gl_PointCoord - 0.5) * 2.0; float a = smoothstep(1.0, 0.0, d) * smoothstep(1.0, 0.0, d) * vC.a * 1.3; if (a < 0.01) discard; gl_FragColor = vec4(vC.rgb, a); }`, | |
| }); | |
| const points = new THREE.Points(pGeo, pMat); | |
| points.frustumCulled = false; | |
| scene.add(points); | |
| // types: 0 dust, 1 ash, 2 smoke, 3 drop, 4 spark | |
| function spawnP(type, x, y, z, vx, vy, vz, size, life, r, g, b, a) { | |
| const i = pHead++ % PMAX; | |
| pSleep[i] = 0; pType[i] = type; pPos[i * 3] = x; pPos[i * 3 + 1] = y; pPos[i * 3 + 2] = z; | |
| pVel[i * 3] = vx; pVel[i * 3 + 1] = vy; pVel[i * 3 + 2] = vz; | |
| pSize[i] = size; pLife[i] = life; pAge[i] = 0; pSeed[i] = Math.random() * 100; | |
| pCol[i * 4] = r; pCol[i * 4 + 1] = g; pCol[i * 4 + 2] = b; pCol[i * 4 + 3] = a; pA0[i] = a; | |
| } | |
| // fragments and flakes: tumbling instanced shards | |
| const FMAX = MOBILE ? 220 : 600; | |
| const shard = new THREE.BufferGeometry(); | |
| { | |
| const v = [0, 0, 0], n = 6; | |
| for (let i = 0; i <= n; i++) { const a = (i / n) * 6.283, r = 0.5 * (0.6 + 0.4 * Math.abs(Math.sin(i * 2.7 + 1.3))); v.push(Math.cos(a) * r, Math.sin(a) * r * 0.7, (i % 2) * 0.06); } | |
| const idx = []; for (let i = 1; i <= n; i++) idx.push(0, i, i + 1); | |
| shard.setAttribute('position', new THREE.Float32BufferAttribute(v, 3)); shard.setIndex(idx); shard.computeVertexNormals(); | |
| } | |
| const fMesh = new THREE.InstancedMesh(shard, new THREE.MeshStandardMaterial({ color: 0x15110e, roughness: 0.95, metalness: 0, side: THREE.DoubleSide }), FMAX); | |
| fMesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage); | |
| fMesh.frustumCulled = false; | |
| fMesh.count = 0; | |
| scene.add(fMesh); | |
| const light = new THREE.HemisphereLight(0xfff0dd, 0x221810, 1.6); | |
| scene.add(light); | |
| const fPos = new Float32Array(FMAX * 3), fVel = new Float32Array(FMAX * 3), fRot = new Float32Array(FMAX * 3), fSpin = new Float32Array(FMAX * 3); | |
| const fSize = new Float32Array(FMAX), fAge = new Float32Array(FMAX), fRest = new Uint8Array(FMAX), fLive = new Uint8Array(FMAX); | |
| let fHead = 0; | |
| // Settle rule: nothing may hang still where it is drawn. In the rolled view the floor is not drawn, so a piece | |
| // resting on it reads as hanging in mid-air; landed pieces therefore fade out soon after they land, and a | |
| // watchdog retires anything that has been drawn without moving for STILL_MAX seconds. | |
| const REST_HOLD = 1.0, REST_FADE = 1.2, STILL_MAX = 2.5; | |
| const fRestT = new Float32Array(FMAX), fStill = new Float32Array(FMAX), fLast = new Float32Array(FMAX * 3), pStill = new Float32Array(PMAX); | |
| let auditMax = 0, auditAir = 0, retired = 0; | |
| const hide = (i) => { tmpM.makeScale(0, 0, 0); fMesh.setMatrixAt(i, tmpM); }; | |
| function spawnF(x, y, z, vx, vy, vz, size) { | |
| const i = fHead++ % FMAX; | |
| fLive[i] = 1; fRest[i] = 0; fAge[i] = 0; fSize[i] = size; fRestT[i] = 0; fStill[i] = 0; | |
| fPos[i * 3] = x; fPos[i * 3 + 1] = y; fPos[i * 3 + 2] = z; | |
| fVel[i * 3] = vx; fVel[i * 3 + 1] = vy; fVel[i * 3 + 2] = vz; | |
| for (let c = 0; c < 3; c++) { fRot[i * 3 + c] = Math.random() * 6.3; fSpin[i * 3 + c] = (Math.random() - 0.5) * 14; } | |
| fMesh.count = Math.max(fMesh.count, Math.min(FMAX, fHead)); | |
| } | |
| // puddles on the floor | |
| const QMAX = 40; | |
| const qMesh = new THREE.InstancedMesh(new THREE.CircleGeometry(1, 28).rotateX(-Math.PI / 2), | |
| new THREE.MeshStandardMaterial({ color: 0x0a0807, roughness: 0.7, metalness: 0, transparent: true, opacity: 0.9 }), QMAX); // black sludge | |
| qMesh.frustumCulled = false; qMesh.count = 0; | |
| scene.add(qMesh); | |
| const puddles = []; | |
| function feedPuddle(x, z, vol) { | |
| let p = puddles.find((q) => Math.hypot(q.x - x, q.z - z) < q.r + 3); | |
| if (!p) { if (puddles.length >= QMAX) puddles.shift(); p = { x, z, vol: 0, r: 0 }; puddles.push(p); } | |
| p.vol += vol; | |
| } | |
| // the debris a hit makes, by size: fine dust hangs, fragments flutter, flakes drop | |
| function debris(world, rMM, n, kind = 'crush') { | |
| const { x, y, z } = world; | |
| for (let i = 0; i < n; i++) { | |
| const a = Math.random() * 6.283, r = Math.random() * rMM; | |
| const big = Math.random() < 0.1; // charcoal powders: a few flakes, mostly small bits | |
| spawnF(x + Math.cos(a) * r, y + Math.sin(a) * r * 0.6, z + 0.4, | |
| Math.cos(a) * (6 + Math.random() * 16), 2 + Math.random() * 10, 6 + Math.random() * 16, | |
| big ? 0.9 + Math.random() * 1.0 : 0.25 + Math.random() * 0.45); | |
| } | |
| const nd = kind === 'tear' ? n * 4 : n * 10; | |
| for (let i = 0; i < nd; i++) { | |
| const a = Math.random() * 6.283, sp = 6 + Math.random() * 18; // a soft puff of black dust | |
| const c = 0.035 + Math.random() * 0.035; | |
| spawnP(0, x + (Math.random() - 0.5) * rMM, y + (Math.random() - 0.5) * rMM, z + 0.5, | |
| Math.cos(a) * sp, Math.sin(a) * sp * 0.6 + 3, 4 + Math.random() * 10, 0.25 + Math.random() * 0.5, 10 + Math.random() * 6, c * 1.15, c, c * 0.9, 0.8); | |
| } | |
| } | |
| function drops(world, n) { | |
| for (let i = 0; i < n; i++) { | |
| const x = world.x + (Math.random() - 0.5) * 3, z = world.z + (Math.random() - 0.5) * 3; | |
| spawnP(3, x, world.y + 25 + Math.random() * 20, z, 0, -60, 0, 0.9 + Math.random() * 0.5, 3, 0.55, 0.65, 0.75, 0.9); | |
| if (Math.random() < 0.5) spawnP(3, x, world.y - 1, z, 0, -5, 0, 0.7, 4, 0.45, 0.55, 0.65, 0.85); // drips off the sheet | |
| } | |
| } | |
| // ------------------------------------------------------------ Crumble: the whole scroll to a heap of dust | |
| // The sheet goes from its edges inward (uCrumble drives a discard in the shaders, so no mask texels are touched); | |
| // the band that has just gone is turned into flakes and dust that are pulled in toward the middle and fall onto | |
| // a heap on the floor. The heap is its own capped point cloud and stays until Reset. | |
| const CR_TIME = 4.2, CR_END = 1.15; | |
| const HMAX = MOBILE ? 900 : 2600; | |
| const hPos = new Float32Array(HMAX * 3), hCol = new Float32Array(HMAX * 4), hSize = new Float32Array(HMAX), hH = new Float32Array(HMAX); | |
| let hCount = 0; | |
| const hGeo = new THREE.BufferGeometry(); | |
| hGeo.setAttribute('position', new THREE.BufferAttribute(hPos, 3).setUsage(THREE.DynamicDrawUsage)); | |
| hGeo.setAttribute('aCol', new THREE.BufferAttribute(hCol, 4).setUsage(THREE.DynamicDrawUsage)); | |
| hGeo.setAttribute('aSize', new THREE.BufferAttribute(hSize, 1).setUsage(THREE.DynamicDrawUsage)); | |
| hGeo.setDrawRange(0, 0); | |
| const heapPts = new THREE.Points(hGeo, new THREE.ShaderMaterial({ | |
| transparent: true, depthWrite: false, uniforms: { uScale: { value: 400 } }, | |
| vertexShader: /* glsl */`attribute vec4 aCol; attribute float aSize; uniform float uScale; varying vec4 vC; | |
| void main() { vC = aCol; vec4 mv = modelViewMatrix * vec4(position, 1.0); gl_Position = projectionMatrix * mv; | |
| gl_PointSize = max(1.0, aSize * uScale / -mv.z); }`, | |
| fragmentShader: /* glsl */`varying vec4 vC; | |
| void main() { float d = length(gl_PointCoord - 0.5) * 2.0; if (d > 1.0) discard; float a = (1.0 - d * d) * vC.a; gl_FragColor = vec4(vC.rgb * (1.1 - 0.4 * d), a); }`, | |
| })); | |
| heapPts.frustumCulled = false; | |
| scene.add(heapPts); | |
| let cr = null; // { t, cx, cz, R, H, wins } | |
| const cP = new THREE.Vector3(), cN = new THREE.Vector3(); | |
| function crumble() { | |
| if (cr || !surfacePoint) return false; | |
| // where the scroll is now: center and spread of the drawn sheet, for the heap | |
| let n = 0, sx = 0, sz = 0, xs = [], zs = []; | |
| for (const m of meshes) for (let i = 0; i < 9; i++) { | |
| if (!surfacePoint(m, 0.1 + 0.8 * (i % 3) / 2, 0.1 + 0.8 * ((i / 3) | 0) / 2, cP)) continue; | |
| n++; sx += cP.x; sz += cP.z; xs.push(cP.x); zs.push(cP.z); | |
| } | |
| if (!n) return false; | |
| const cx = sx / n, cz = sz / n; | |
| let spread = 0; for (let i = 0; i < n; i++) spread = Math.max(spread, Math.hypot(xs[i] - cx, (zs[i] - cz))); | |
| const R = Math.min(34, Math.max(12, spread * 0.12)); | |
| cr = { t: 0, cx, cz, R, H: R * 0.75, last: 0, done: false }; | |
| for (const k of masks.values()) { emberCount -= k.embers.length; k.embers = []; } // nothing left to burn | |
| return true; | |
| } | |
| function addHeapGrain(f) { | |
| if (hCount >= HMAX) return; | |
| const i = hCount++, a = Math.random() * 6.283; | |
| // a mound: dense and tall in the middle, thin at the rim; it grows wider and taller as the scroll comes down | |
| const r = cr.R * (0.35 + 0.65 * f) * Math.pow(Math.random(), 0.8) * (0.9 + Math.random() * 0.2); | |
| const top = cr.H * (0.3 + 0.7 * f) * Math.pow(Math.max(0, 1 - r / cr.R), 1.6); | |
| hH[i] = top * Math.pow(Math.random(), 0.6) + 0.2; | |
| hPos[i * 3] = cr.cx + Math.cos(a) * r; hPos[i * 3 + 2] = cr.cz + Math.sin(a) * r * 0.85; | |
| const c = 0.03 + Math.random() * 0.07, warm = Math.random() < 0.1 ? 0.02 : 0; | |
| hCol[i * 4] = c * 1.3 + warm; hCol[i * 4 + 1] = c; hCol[i * 4 + 2] = c * 0.75; hCol[i * 4 + 3] = 0; | |
| hSize[i] = 0.9 + Math.random() * 1.4 + (1 - r / cr.R) * 0.8; // coarser in the core | |
| } | |
| function stepCrumble(wall, dt) { | |
| if (!cr) return; | |
| const G = ground(); | |
| if (!cr.done) { | |
| cr.t += wall; | |
| const f = Math.min(1, cr.t / CR_TIME), front = CR_END * (f * f * (3 - 2 * f)); | |
| const prev = shared.uCrumble.value; | |
| shared.uCrumble.value = Math.max(0.001, front); | |
| // pieces from the band that just went: each starts on the sheet where it was, falls in toward the middle | |
| const band = Math.max(0.004, front - prev); | |
| const nF = Math.min(18, Math.round(wall * 120)), nD = Math.min(40, Math.round(wall * 300)); | |
| for (let q = 0; q < nF + nD; q++) { | |
| const m = meshes[(Math.random() * meshes.length) | 0], w = m.userData.w; | |
| const mx = w.widthMM, my = w.heightMM, hm = 0.5 * Math.min(mx, my); | |
| // a UV at edge distance in [prev, front): pick the side, then the depth in from it | |
| const dd = Math.min(1, prev + Math.random() * band) * hm, side = (Math.random() * 4) | 0, t = Math.random(); | |
| let u, v; | |
| if (side === 0) { u = dd / mx; v = t; } else if (side === 1) { u = 1 - dd / mx; v = t; } | |
| else if (side === 2) { u = t; v = dd / my; } else { u = t; v = 1 - dd / my; } | |
| if (!surfacePoint(m, u, v, cP, cN)) continue; | |
| const ix = cr.cx - cP.x, iz = cr.cz - cP.z, il = Math.hypot(ix, iz) || 1, pull = 25 + Math.random() * 45; | |
| if (q < nF) spawnF(cP.x, cP.y, cP.z, ix / il * pull, Math.random() * 15, iz / il * pull, 0.35 + Math.random() * (Math.random() < 0.2 ? 1.6 : 0.6)); | |
| else { | |
| const c = 0.035 + Math.random() * 0.04; | |
| spawnP(0, cP.x, cP.y, cP.z, ix / il * pull * 0.6, 4 + Math.random() * 10, iz / il * pull * 0.6, 0.3 + Math.random() * 0.5, 3 + Math.random() * 2, c * 1.25, c * 0.95, c * 0.75, 0.75); | |
| } | |
| } | |
| // the heap fills in step with what has come down | |
| const want = Math.floor(HMAX * f); | |
| while (hCount < want) addHeapGrain(f); | |
| if (f >= 1) { cr.done = true; shared.uCrumble.value = CR_END + 0.5; } | |
| } | |
| // grains settle in: fade up over half a second, sit on the floor wherever it is | |
| for (let i = 0; i < hCount; i++) { | |
| hPos[i * 3 + 1] = G + hH[i]; | |
| if (hCol[i * 4 + 3] < 0.95) hCol[i * 4 + 3] = Math.min(0.95, hCol[i * 4 + 3] + dt * 2); | |
| } | |
| hGeo.setDrawRange(0, hCount); | |
| hGeo.attributes.position.needsUpdate = hGeo.attributes.aCol.needsUpdate = hGeo.attributes.aSize.needsUpdate = true; | |
| } | |
| // ------------------------------------------------------------ update | |
| const tmpM = new THREE.Matrix4(), tmpQ = new THREE.Quaternion(), tmpE = new THREE.Euler(), tmpS = new THREE.Vector3(), tmpP = new THREE.Vector3(); | |
| let burnTick = 0, wetTick = 0, upTick = 0, decayTick = 0; | |
| function update(dt, time, wall = dt) { | |
| shared.uDmgT.value = time; | |
| // burning: slow ember creep, faster along the fibers, char behind it, smoke and ash above | |
| burnTick += wall; | |
| if (burnTick > 0.1) { | |
| const h = burnTick; burnTick = 0; | |
| const q = h / 0.1; // per-tick amounts are tuned for 0.1 s; scale so slow frames burn at the same speed | |
| for (const k of masks.values()) { | |
| if (!k.embers.length) continue; | |
| const keep = []; | |
| for (const e of k.embers) { | |
| e.age += h; e.life -= h; e.next -= h; | |
| blob(k, e.u, e.v, 1.4, 2, Math.min(1, 0.22 * q)); | |
| blob(k, e.u, e.v, 2.6, 2, Math.min(1, 0.05 * q)); | |
| blob(k, e.u, e.v, 0.8, 0, Math.min(1, (e.age > 1.2 ? 0.05 : 0.01) * q)); | |
| for (let rep = 0; rep < 3 && e.next <= 0 && e.gen < 40; rep++) { | |
| e.next += 0.6 + Math.random() * 1.0; // smoldering creeps slowly | |
| const along = Math.random() < 0.7, s = 0.6 + Math.random() * 0.8, a = Math.random() < 0.5 ? -1 : 1; | |
| const [du, dv] = mmToUV(k, along ? a * s * 1.9 : (Math.random() - 0.5) * s * 1.4, along ? (Math.random() - 0.5) * s * 0.6 : a * s * 1.0); | |
| if (Math.random() < 0.97 - e.gen * 0.01) addEmber(k, e.u + du, e.v + dv, e.gen + 1); | |
| } | |
| if (e.life > 0) keep.push(e); | |
| else { | |
| blob(k, e.u, e.v, 1.3, 0, 0.6); emberCount--; | |
| // the burnt-out spot drops a flake from where it is on the sheet now | |
| if (Math.random() < 0.5 && emberAt(k, e, 0.3)) spawnF(sp.x, sp.y, sp.z, (Math.random() - 0.5) * 10 + sn.x * 4, 5, (Math.random() - 0.5) * 4 + sn.z * 4, 0.3 + Math.random() * 0.4); | |
| } | |
| } | |
| k.embers = keep; | |
| if (keep.length) { | |
| // smoke, ash and sparks rise from live embers, each from that ember's current spot on the deformed sheet | |
| const pick = () => emberAt(k, keep[(Math.random() * keep.length) | 0], 1); | |
| for (let i = 0; i < Math.min(3, keep.length / 6 + 1); i++) { | |
| const g = 0.16 + Math.random() * 0.1; | |
| if (pick()) spawnP(2, sp.x, sp.y, sp.z, | |
| (Math.random() - 0.5) * 6 + sn.x * 4, 8 + Math.random() * 6, (Math.random() - 0.5) * 4 + sn.z * 4, 3 + Math.random() * 3, 4 + Math.random() * 3, g * 1.8, g * 1.75, g * 1.7, 0.22); | |
| } | |
| if (Math.random() < 0.6 && pick()) spawnP(1, sp.x, sp.y, sp.z, | |
| (Math.random() - 0.5) * 8 + sn.x * 3, 12 + Math.random() * 10, sn.z * 3, 0.4 + Math.random() * 0.5, 8, 0.58, 0.56, 0.53, 0.8); // light gray ash | |
| if (Math.random() < 0.02 && pick()) spawnP(4, sp.x, sp.y, sp.z, (Math.random() - 0.5) * 10 + sn.x * 6, 25 + Math.random() * 15, sn.z * 6, 0.18, 0.7, 0.8, 0.3, 0.06, 0.7); // a rare dull spark | |
| } | |
| } | |
| } | |
| // ember glow cools where nothing burns any more | |
| decayTick += wall; | |
| if (decayTick > 0.25) { | |
| const f = Math.exp(-decayTick * 0.6); decayTick = 0; | |
| for (const k of masks.values()) { | |
| if (!k.hot && !k.embers.length) continue; | |
| const d = k.data; let any = false; | |
| for (let i = 2; i < d.length; i += 4) if (d[i]) { d[i] = d[i] * f; any = true; } | |
| k.hot = any; k.dirty = true; | |
| } | |
| } | |
| // soaked spots slowly dissolve and delaminate | |
| wetTick += wall; | |
| if (wetTick > 0.4) { | |
| wetTick = 0; | |
| for (const k of masks.values()) for (const w of k.wet) { | |
| w.t += 0.4; | |
| if (w.t < 6) blob(k, w.u, w.v, w.r * (1 + w.t * 0.08), 3, 0.05, 1.6); // water wicks out along the fibers | |
| if (w.t < 2.5 || at(k, w.u, w.v, 3) < 0.55 || Math.random() > 0.35) continue; | |
| const a = Math.random() * 6.283, r = Math.random() * w.r * 0.8; | |
| const [du, dv] = mmToUV(k, Math.cos(a) * r, Math.sin(a) * r); | |
| blob(k, w.u + du, w.v + dv, 0.5 + Math.random() * 0.6, 0, 0.2); | |
| if (Math.random() < 0.5) blob(k, w.u + du, w.v + dv, 1.2, 1, 0.2); // layers lift apart along the fibers | |
| // the mush lets go: black sludge drips and a few sodden fragments drop from where it is on the sheet | |
| if (surfacePoint && surfacePoint(k.m, w.u + du, w.v + dv, sp)) { | |
| spawnP(3, sp.x, sp.y - 0.3, sp.z + 0.2, (Math.random() - 0.5) * 3, -4, 0, 0.9 + Math.random() * 0.6, 4, 0.05, 0.04, 0.035, 0.95); | |
| if (Math.random() < 0.35) spawnF(sp.x, sp.y, sp.z + 0.2, (Math.random() - 0.5) * 4, -2, 1, 0.3 + Math.random() * 0.5); | |
| } | |
| } | |
| } | |
| // texture uploads, throttled | |
| upTick += dt; | |
| if (upTick > (MOBILE ? 0.09 : 0.05)) { | |
| upTick = 0; | |
| for (const k of masks.values()) if (k.dirty) { k.tex.needsUpdate = true; k.dirty = false; } | |
| } | |
| stepCrumble(Math.min(wall, 0.25), dt); | |
| stepParticles(dt, time); | |
| } | |
| function stepParticles(dt, time) { | |
| const G = ground(); | |
| for (let i = 0; i < PMAX; i++) { | |
| if (pCol[i * 4 + 3] <= 0) continue; | |
| pAge[i] += dt; | |
| const t = pType[i], o = i * 3, life = pLife[i]; | |
| if (pAge[i] > life) { pCol[i * 4 + 3] = 0; continue; } | |
| if (pSleep[i]) { // landed: asleep on the floor (which follows the scroll), only fading | |
| pPos[o + 1] = G + 0.05; | |
| pCol[i * 4 + 3] = Math.min(pCol[i * 4 + 3], Math.max(0, 1 - pAge[i] / life) * 0.9); | |
| continue; | |
| } | |
| let g, k, turb; | |
| if (t === 0) { g = 9; k = 3.6; turb = 8; } // charcoal dust, very light: settles at about 2.5 mm/s | |
| else if (t === 1) { g = 6; k = 1.6; turb = 12; } // ash: warm air carries it up first | |
| else if (t === 2) { g = -14; k = 1.1; turb = 14; } // smoke rises | |
| else if (t === 3) { g = 520; k = 0.3; turb = 0; } // water drops | |
| else { g = 40; k = 1.2; turb = 6; } // sparks | |
| const x = pPos[o], y = pPos[o + 1], z = pPos[o + 2], s = pSeed[i], tt = time * 0.8; | |
| const tx = Math.sin(0.11 * y + tt + s) + Math.sin(0.09 * z - 0.7 * tt + 1.3); | |
| const ty = Math.sin(0.10 * z + 0.6 * tt + 2.1 + s) + Math.sin(0.12 * x - 0.8 * tt); | |
| const tz = Math.sin(0.1 * x + 0.75 * tt + 0.7) + Math.sin(0.09 * y + 0.5 * tt + 3.0 + s); | |
| const e = Math.exp(-k * dt); | |
| pVel[o] = pVel[o] * e + tx * turb * dt; | |
| pVel[o + 1] = pVel[o + 1] * e + (ty * turb * 0.6 - g) * dt; | |
| pVel[o + 2] = pVel[o + 2] * e + tz * turb * dt; | |
| pPos[o] += pVel[o] * dt; pPos[o + 1] += pVel[o + 1] * dt; pPos[o + 2] += pVel[o + 2] * dt; | |
| const fade = 1 - pAge[i] / life; | |
| // guard: anything broken is recycled | |
| if (!Number.isFinite(pPos[o] + pPos[o + 1] + pPos[o + 2] + pVel[o] + pVel[o + 1] + pVel[o + 2])) { pCol[i * 4 + 3] = 0; continue; } | |
| if (pPos[o + 1] <= G) { | |
| if (t === 3) { feedPuddle(pPos[o], pPos[o + 2], 1); pCol[i * 4 + 3] = 0; continue; } | |
| if (t === 2 || t === 4) { pCol[i * 4 + 3] = 0; continue; } | |
| // dust and ash land: snap to the floor, stop, sleep | |
| pPos[o + 1] = G + 0.05; pVel[o] = pVel[o + 1] = pVel[o + 2] = 0; pSleep[i] = 1; | |
| pLife[i] = pAge[i] + REST_HOLD + REST_FADE + Math.random() * 0.5; | |
| continue; | |
| } | |
| if (t === 2) { pSize[i] += dt * 2.6; pCol[i * 4 + 3] = pA0[i] * 0.84 * fade * Math.min(1, pAge[i] * 2); } | |
| else if (t === 4) pCol[i * 4 + 3] = fade * pA0[i]; | |
| else if (t !== 3) pCol[i * 4 + 3] = Math.min(0.8, fade * 1.6) * (t === 0 ? 0.75 : 0.9); | |
| } | |
| // watchdog for particles: visible, not asleep on the floor, and not moving | |
| for (let i = 0; i < PMAX; i++) { | |
| if (pCol[i * 4 + 3] <= 0 || pSleep[i]) { pStill[i] = 0; continue; } | |
| const sp = Math.abs(pVel[i * 3]) + Math.abs(pVel[i * 3 + 1]) + Math.abs(pVel[i * 3 + 2]); | |
| pStill[i] = sp < 0.05 ? pStill[i] + dt : 0; | |
| if (pStill[i] > STILL_MAX) { pCol[i * 4 + 3] = 0; pStill[i] = 0; retired++; } | |
| } | |
| pGeo.attributes.position.needsUpdate = pGeo.attributes.aCol.needsUpdate = pGeo.attributes.aSize.needsUpdate = true; | |
| // fragments flutter and tumble with lift; big flakes drop | |
| for (let i = 0; i < fMesh.count; i++) { | |
| const o = i * 3; | |
| if (!fLive[i]) { hide(i); continue; } | |
| fAge[i] += dt; | |
| if (!Number.isFinite(fPos[o] + fPos[o + 1] + fPos[o + 2] + fVel[o] + fVel[o + 1] + fVel[o + 2] + fRot[o] + fRot[o + 1] + fRot[o + 2])) { fLive[i] = 0; hide(i); continue; } | |
| if (!fRest[i]) { | |
| const big = fSize[i] > 1.0; | |
| const g = big ? 380 : 160, k = big ? 0.6 : 3.0; | |
| const tilt = Math.abs(Math.sin(fRot[o])), flut = big ? 0 : 55; | |
| const e = Math.exp(-k * (1 + 1.5 * tilt) * dt); | |
| fVel[o] = fVel[o] * e + Math.sin(fAge[i] * 7 + i) * flut * dt; | |
| fVel[o + 1] = fVel[o + 1] * e - g * dt; | |
| fVel[o + 2] = fVel[o + 2] * e + Math.cos(fAge[i] * 5.3 + i) * flut * 0.6 * dt; | |
| for (let c = 0; c < 3; c++) { fPos[o + c] += fVel[o + c] * dt; fRot[o + c] += fSpin[o + c] * dt; } | |
| if (fPos[o + 1] < ground() + 0.1) { fPos[o + 1] = ground() + 0.1; fRest[i] = 1; fRot[o] = Math.PI / 2 + (Math.random() - 0.5) * 0.3; } | |
| } | |
| // landed: lie a moment, then fade; airborne pieces still fade by age as a backstop | |
| if (fRest[i]) { fRestT[i] += dt; fPos[o + 1] = ground() + 0.1; } | |
| const fadeS = Math.min(fAge[i] > 25 ? 1 - (fAge[i] - 25) / 5 : 1, fRest[i] ? 1 - (fRestT[i] - REST_HOLD) / REST_FADE : 1, 1); | |
| if (fadeS <= 0) { fLive[i] = 0; hide(i); continue; } | |
| tmpE.set(fRot[o], fRot[o + 1], fRot[o + 2]); | |
| tmpQ.setFromEuler(tmpE); | |
| tmpS.setScalar(fSize[i] * fadeS); | |
| tmpP.set(fPos[o], fPos[o + 1], fPos[o + 2]); | |
| tmpM.compose(tmpP, tmpQ, tmpS); | |
| fMesh.setMatrixAt(i, tmpM); | |
| } | |
| audit(dt); | |
| fMesh.instanceMatrix.needsUpdate = true; | |
| // puddles spread and slowly soak away | |
| const G2 = ground() + 0.05; | |
| qMesh.count = puddles.length; | |
| puddles.forEach((p, i) => { | |
| p.vol *= Math.exp(-dt * 0.02); | |
| const want = Math.sqrt(p.vol) * 1.4; | |
| p.r += (want - p.r) * Math.min(1, dt * 2); | |
| tmpM.compose(tmpP.set(p.x, G2, p.z), tmpQ.identity(), tmpS.set(p.r, 1, p.r * 0.8)); | |
| qMesh.setMatrixAt(i, tmpM); | |
| }); | |
| qMesh.instanceMatrix.needsUpdate = true; | |
| } | |
| // Audit what is actually drawn, from the instance matrices themselves, so a stale or orphaned slot counts too: | |
| // a slot with a visible scale whose position has not changed. Past STILL_MAX it is hidden and freed. | |
| function audit(dt) { | |
| const a = fMesh.instanceMatrix.array, G = ground(); | |
| let mx = 0, air = 0; | |
| for (let i = 0; i < FMAX; i++) { | |
| const j = i * 16, o = i * 3; | |
| const drawn = i < fMesh.count && a[j] * a[j] + a[j + 1] * a[j + 1] + a[j + 2] * a[j + 2] > 1e-8; | |
| const moved = Math.abs(a[j + 12] - fLast[o]) + Math.abs(a[j + 13] - fLast[o + 1]) + Math.abs(a[j + 14] - fLast[o + 2]) > 1e-4; | |
| fLast[o] = a[j + 12]; fLast[o + 1] = a[j + 13]; fLast[o + 2] = a[j + 14]; | |
| fStill[i] = drawn && !moved && dt > 0 ? fStill[i] + dt : drawn && !moved ? fStill[i] : 0; | |
| if (fStill[i] > STILL_MAX) { fLive[i] = 0; hide(i); fStill[i] = 0; retired++; continue; } | |
| if (fStill[i] > mx) mx = fStill[i]; | |
| if (fStill[i] > 0 && a[j + 13] > G + 0.5) air = Math.max(air, fStill[i]); | |
| } | |
| auditMax = Math.max(auditMax, mx); auditAir = Math.max(auditAir, air); | |
| } | |
| function reset() { | |
| for (const k of masks.values()) { own(k.m).uDmgOn.value = 0; own(k.m).uDmg.value = blankTex; k.tex.dispose(); } | |
| masks.clear(); | |
| emberCount = 0; | |
| cr = null; shared.uCrumble.value = 0; hCount = 0; hGeo.setDrawRange(0, 0); | |
| pCol.fill(0); fLive.fill(0); pStill.fill(0); fStill.fill(0); | |
| for (let i = 0; i < FMAX; i++) hide(i); // no stale matrices left in the pool | |
| fMesh.instanceMatrix.needsUpdate = true; fMesh.count = 0; fHead = 0; puddles.length = 0; qMesh.count = 0; | |
| } | |
| const blankTex = new THREE.DataTexture(new Uint8Array(4), 1, 1); blankTex.needsUpdate = true; | |
| const belowFloor = () => { const G = ground(); let n = 0, sleeping = 0; for (let i = 0; i < PMAX; i++) if (pCol[i * 4 + 3] > 0) { if (pPos[i * 3 + 1] < G - 0.01) n++; if (pSleep[i]) sleeping++; } return { n, sleeping }; }; | |
| const stats = () => ({ floor: belowFloor(), masks: masks.size, embers: emberCount, particles: [...pCol].filter((_, i) => i % 4 === 3).filter((a) => a > 0).length, shards: fLive.reduce((a, b) => a + b, 0), puddles: puddles.length, crumble: cr ? +shared.uCrumble.value.toFixed(3) : 0, crumbleDone: !!(cr && cr.done), heap: hCount, ground: +ground().toFixed(2), emberSpawns, emberFallback, | |
| stillMax: +auditMax.toFixed(2), stillAirMax: +auditAir.toFixed(2), retired }); | |
| // map a material-space hit (mm) from the flat sandbox onto the same mask | |
| function crushMM(m, xMM, yMM, rMM) { const w = m.userData.w; crush(m, xMM / w.widthMM, yMM / w.heightMM, rMM, 1.0); } | |
| function tearMM(m, ax, ay, bx, by) { const w = m.userData.w; const k = mask(m); line(k, ax / w.widthMM, ay / w.heightMM, bx / w.widthMM, by / w.heightMM, 0.28, 0, 0.75, 0.3); line(k, ax / w.widthMM, ay / w.heightMM, bx / w.widthMM, by / w.heightMM, 0.8, 1, 0.3, 0.4); } | |
| const objects = [points, fMesh, qMesh, heapPts]; | |
| return { objects, shared, blankTex, bruise, crush, tear, wetAt, ignite, debris, drops, update, reset, stats, crumble, simulate: (n, dt) => { for (let i = 0; i < n; i++) update(dt, shared.uDmgT.value + dt, dt); return stats(); }, crushMM, tearMM, wetness: (m, u, v) => (masks.has(m) ? at(masks.get(m), u, v, 3) : 0), dust: (x, y, z) => debris({ x, y, z }, 1.5, 0, 'tear') || spawnP(0, x, y, z, (Math.random() - 0.5) * 30, 8 + Math.random() * 12, 8 + Math.random() * 15, 0.3 + Math.random() * 0.4, 10, 0.05, 0.043, 0.038, 0.8) }; | |
| } | |