openvibers/openvibe.games Commits
Scraplandia: something flies off a hit, wood chips off a tree, grit off a rock, sparks off scrap, leaves off a bush (a swing changed nothing on screen until the server moved a count), never toward the camera (#114)
Changes
@@@ -27,6 +27,9 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).### Added++- **Something flies off a hit.** Wood chips off a tree or branches, grit off a rock, sparks off scrap, leaves off a++ bush. Before, a swing changed nothing on screen until the server moved a count. Nothing flies toward the camera, and a++ point-blank hit throws smaller pieces. Two thin-instanced meshes draw every particle.- **Sound.** Scraplandia had none. Every sound is synthesised in the browser, so there are no files to load:- footsteps on grass, stone and water;- a jump and a landing as hard as the fall;@@@ -21,6 +21,7 @@ import { Scene } from '@babylonjs/core/scene.js'import { WATER_LEVEL, terrainHeight } from '@openvibe/content'import { DEFAULT_MOVEMENT } from '@openvibe/gameplay'import { Sfx, footstepSurface, materialForVisual } from './audio/sfx.js'++import { HitChips } from './render/hitChips.js'import { InteractionController } from './game/interactionController.js'import { LocalPlayer } from './game/localPlayer.js'import { InputTracker } from './input/inputTracker.js'@@@ -352,6 +353,7 @@ async function start(): Promise<void> {// Sounds. Browsers start audio only after a gesture: the first click or key.const sfx = new Sfx()++ const chips = new HitChips(scene)const unlockAudio = (): void => sfx.unlock()window.addEventListener('pointerdown', unlockAudio)window.addEventListener('keydown', unlockAudio)@@@ -368,8 +370,12 @@ async function start(): Promise<void> {: target.def && content.item(target.def)?.health? 'wood': null-- // The blow lands mid-swing, after the whoosh starts.-- if (material) setTimeout(() => sfx.hit(material), 90)++ // The blow lands mid-swing, after the whoosh starts: its sound, and what flies off.++ if (material)++ setTimeout(() => {++ sfx.hit(material)++ chips.burst(target.point, target.normal, material, player.eye)++ }, 90)}let packCount = -1state.events.on('inventory', () => {@@@ -646,6 +652,7 @@ async function start(): Promise<void> {}wasGrounded = groundedsfx.update(elapsed, environment.daylight)++ chips.update(elapsed)// Beams (modular: any weapon whose module declares firesBeam gets this// muzzle-anchored beam pipeline). Mine fires whenever the trigger isnnew file mode 100644@@@ -0,0 +1,72 @@++/**++ * Hit particles live in two thin-instanced meshes. What must hold: a hit++ * throws its burst, the burst falls and is gone within a second, an empty++ * pool draws nothing (a mesh with no thin instances would draw itself, a++ * metre cube at the origin), and a hit on a person throws nothing.++ */++import { afterEach, beforeEach, describe, expect, it } from 'vitest'++import { NullEngine } from '@babylonjs/core/Engines/nullEngine.js'++import { Scene } from '@babylonjs/core/scene.js'++import type { Mesh } from '@babylonjs/core/Meshes/mesh.js'++import { HitChips } from './hitChips.js'++++let engine: NullEngine++let scene: Scene++beforeEach(() => {++ engine = new NullEngine()++ scene = new Scene(engine)++})++afterEach(() => {++ scene.dispose()++ engine.dispose()++})++const mesh = (name: string) => scene.getMeshByName(name) as Mesh++const step = (chips: HitChips, seconds: number) => {++ for (let t = 0; t < seconds; t += 1 / 60) chips.update(1 / 60)++}++++describe('HitChips', () => {++ it('draws nothing until something is hit', () => {++ const chips = new HitChips(scene)++ chips.update(1 / 60)++ for (const name of ['fx:chips', 'fx:sparks']) {++ expect(mesh(name).thinInstanceCount).toBe(0)++ expect(mesh(name).isVisible).toBe(false)++ }++ })++++ it('throws wood chips off a tree that fall and are gone within a second', () => {++ const chips = new HitChips(scene)++ chips.burst({ x: 0, y: 1, z: 0 }, { x: 0, y: 0, z: -1 }, 'wood')++ chips.update(1 / 60)++ const m = mesh('fx:chips')++ expect(m.thinInstanceCount).toBe(9)++ expect(m.isVisible).toBe(true)++ step(chips, 1.2)++ expect(m.thinInstanceCount).toBe(0)++ expect(m.isVisible).toBe(false)++ })++++ it('throws sparks, not chips, off scrap', () => {++ const chips = new HitChips(scene)++ chips.burst({ x: 0, y: 1, z: 0 }, { x: 1, y: 0, z: 0 }, 'metal')++ chips.update(1 / 60)++ expect(mesh('fx:sparks').thinInstanceCount).toBe(10)++ expect(mesh('fx:chips').thinInstanceCount).toBe(0)++ })++++ it('throws nothing off a person', () => {++ const chips = new HitChips(scene)++ chips.burst({ x: 0, y: 1, z: 0 }, { x: 1, y: 0, z: 0 }, 'flesh')++ chips.update(1 / 60)++ expect(mesh('fx:chips').thinInstanceCount).toBe(0)++ expect(mesh('fx:sparks').thinInstanceCount).toBe(0)++ })++++ it('keeps at most 96 in flight, dropping the oldest', () => {++ const chips = new HitChips(scene)++ for (let i = 0; i < 20; i++) chips.burst({ x: 0, y: 1, z: 0 }, { x: 0, y: 1, z: 0 }, 'wood')++ chips.update(1 / 60)++ expect(mesh('fx:chips').thinInstanceCount).toBe(96)++ })++})nnew file mode 100644@@@ -0,0 +1,214 @@++import '@babylonjs/core/Meshes/thinInstanceMesh.js' // thinInstanceSetBuffer() needs its side effect++import { Color3 } from '@babylonjs/core/Maths/math.color.js'++import { Matrix, Quaternion, Vector3 } from '@babylonjs/core/Maths/math.vector.js'++import { StandardMaterial } from '@babylonjs/core/Materials/standardMaterial.js'++import { CreateBox } from '@babylonjs/core/Meshes/Builders/boxBuilder.js'++import type { Mesh } from '@babylonjs/core/Meshes/mesh.js'++import type { Scene } from '@babylonjs/core/scene.js'++import type { HitMaterial } from '../audio/sfx.js'++++/**++ * What flies off a hit: wood chips off a tree, grit off a rock, sparks off++ * scrap, leaves off a bush. A hit used to change nothing on screen until the++ * server's reply moved a count, so a swing felt like it went through.++ *++ * Two thin-instanced meshes (lit chips, unlit sparks) hold every particle:++ * two draw calls however many are flying, nothing allocated per hit.++ */++++const MAX = 96++const GRAVITY = 12++++interface Particle {++ pos: Vector3++ vel: Vector3++ axis: Vector3++ angle: number++ spin: number++ size: number++ age: number++ life: number++ gravity: number++ color: [number, number, number]++}++++interface Kind {++ count: number++ colors: string[]++ spark?: boolean++ speed: number++ size: number++ life: number++ gravity: number++}++++const KINDS: Record<Exclude<HitMaterial, 'flesh'>, Kind> = {++ wood: {++ count: 9,++ colors: ['#8a5a2b', '#a87437', '#6d4c2a', '#c49a62'],++ speed: 2,++ size: 0.045,++ life: 0.7,++ gravity: 1,++ },++ stone: {++ count: 8,++ colors: ['#8d8d88', '#a3a39e', '#6f6f6a'],++ speed: 2.3,++ size: 0.035,++ life: 0.55,++ gravity: 1,++ },++ metal: {++ count: 10,++ colors: ['#ffd27a'],++ spark: true,++ speed: 4.5,++ size: 0.025,++ life: 0.35,++ gravity: 0.6,++ },++ plant: {++ count: 6,++ colors: ['#4a7a3a', '#5d8f3f', '#7d9a45'],++ speed: 1.2,++ size: 0.045,++ life: 0.9,++ gravity: 0.3,++ },++}++++export class HitChips {++ private readonly chips: Pool++ private readonly sparks: Pool++++ constructor(scene: Scene) {++ const chipMat = new StandardMaterial('fx:chips', scene)++ chipMat.diffuseColor = Color3.White()++ chipMat.specularColor = new Color3(0.05, 0.05, 0.05)++ const sparkMat = new StandardMaterial('fx:sparks', scene)++ sparkMat.disableLighting = true++ sparkMat.emissiveColor = new Color3(1, 0.78, 0.35)++ this.chips = new Pool(scene, 'fx:chips', chipMat, true)++ this.sparks = new Pool(scene, 'fx:sparks', sparkMat, false)++ }++++ /**++ * Throws a burst off `point`, out to the sides and up from the surface++ * `normal`. Nothing flies toward `eye` (the camera), and a hit right in++ * front of it throws smaller pieces. Hits on people throw nothing.++ */++ burst(point: Vector3Like, normal: Vector3Like, material: HitMaterial, eye?: Vector3Like): void {++ if (material === 'flesh') return++ const kind = KINDS[material]++ const pool = kind.spark ? this.sparks : this.chips++ const n = new Vector3(normal.x, normal.y, normal.z)++ if (n.lengthSquared() < 1e-6) n.set(0, 1, 0)++ n.normalize()++ const toEye = eye ? new Vector3(eye.x - point.x, eye.y - point.y, eye.z - point.z) : null++ const near = toEye ? Math.min(1, toEye.length() / 1.5) : 1++ toEye?.normalize()++ for (let i = 0; i < kind.count; i++) {++ const hex = kind.colors[Math.floor(Math.random() * kind.colors.length)]!++ const c = Color3.FromHexString(hex)++ const spread = new Vector3(++ Math.random() - 0.5,++ Math.random() * 0.8,++ Math.random() - 0.5,++ ).scaleInPlace(2)++ const speed = kind.speed * (0.6 + Math.random() * 0.6)++ const vel = n++ .scale(0.45)++ .addInPlace(spread)++ .normalize()++ .scaleInPlace(speed)++ .addInPlace(new Vector3(0, 1.2, 0))++ if (toEye) {++ const toward = Vector3.Dot(vel, toEye)++ if (toward > 0) vel.subtractInPlace(toEye.scale(toward))++ }++ pool.add({++ pos: new Vector3(point.x, point.y, point.z).addInPlace(n.scale(0.05)),++ vel,++ axis: new Vector3(++ Math.random() - 0.5,++ Math.random() - 0.5,++ Math.random() - 0.5,++ ).normalize(),++ angle: Math.random() * Math.PI,++ spin: (Math.random() - 0.5) * 18,++ size: kind.size * (0.6 + Math.random() * 0.8) * Math.max(0.35, near),++ age: 0,++ life: kind.life * (0.7 + Math.random() * 0.6),++ gravity: GRAVITY * kind.gravity,++ color: [c.r, c.g, c.b],++ })++ }++ }++++ update(dt: number): void {++ this.chips.update(dt)++ this.sparks.update(dt)++ }++}++++interface Vector3Like {++ x: number++ y: number++ z: number++}++++/** One thin-instanced cube and the particles it draws, the live ones packed at the front. */++class Pool {++ private readonly mesh: Mesh++ private readonly live: Particle[] = []++ private readonly matrices = new Float32Array(MAX * 16)++ private readonly colors: Float32Array | null++ private readonly m = new Matrix()++ private readonly q = new Quaternion()++ private readonly s = new Vector3()++++ constructor(scene: Scene, name: string, material: StandardMaterial, colored: boolean) {++ this.mesh = CreateBox(name, { size: 1 }, scene)++ this.mesh.material = material++ this.mesh.isPickable = false++ this.mesh.alwaysSelectAsActiveMesh = true // particles fly outside the unit box's bounds++ this.mesh.thinInstanceSetBuffer('matrix', this.matrices, 16, false)++ this.colors = colored ? new Float32Array(MAX * 4).fill(1) : null++ if (this.colors) this.mesh.thinInstanceSetBuffer('color', this.colors, 4, false)++ this.mesh.thinInstanceCount = 0++ // With no thin instances a mesh draws itself: a metre cube at the origin.++ this.mesh.isVisible = false++ }++++ add(p: Particle): void {++ if (this.live.length >= MAX) this.live.shift()++ this.live.push(p)++ }++++ update(dt: number): void {++ if (this.live.length === 0 && this.mesh.thinInstanceCount === 0) return++ let w = 0++ for (const p of this.live) {++ p.age += dt++ if (p.age >= p.life) continue++ p.vel.y -= p.gravity * dt++ p.pos.addInPlaceFromFloats(p.vel.x * dt, p.vel.y * dt, p.vel.z * dt)++ p.angle += p.spin * dt++ // Shrink away over the last third of the life.++ const left = 1 - p.age / p.life++ const size = p.size * Math.min(1, left * 3)++ Quaternion.RotationAxisToRef(p.axis, p.angle, this.q)++ this.s.setAll(size)++ Matrix.ComposeToRef(this.s, this.q, p.pos, this.m)++ this.m.copyToArray(this.matrices, w * 16)++ if (this.colors) this.colors.set([p.color[0], p.color[1], p.color[2], 1], w * 4)++ this.live[w++] = p++ }++ this.live.length = w++ this.mesh.thinInstanceCount = w++ this.mesh.isVisible = w > 0++ this.mesh.thinInstanceBufferUpdated('matrix')++ if (this.colors) this.mesh.thinInstanceBufferUpdated('color')++ }++}nnew file mode 100644@@@ -0,0 +1,3 @@++// file: 'apps/client/src/render/hitChips.test.ts'++// covers: 'apps/client/src/render'++// Generated by node test/run.js --index; do not edit.
@@ -27,6 +27,9 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
@@ -27,6 +27,9 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
### Added
### Added
+- **Something flies off a hit.** Wood chips off a tree or branches, grit off a rock, sparks off scrap, leaves off a
+ bush. Before, a swing changed nothing on screen until the server moved a count. Nothing flies toward the camera, and a
+ point-blank hit throws smaller pieces. Two thin-instanced meshes draw every particle.
- **Sound.** Scraplandia had none. Every sound is synthesised in the browser, so there are no files to load:
- **Sound.** Scraplandia had none. Every sound is synthesised in the browser, so there are no files to load:
- footsteps on grass, stone and water;
- footsteps on grass, stone and water;
- a jump and a landing as hard as the fall;
- a jump and a landing as hard as the fall;
@@ -21,6 +21,7 @@ import { Scene } from '@babylonjs/core/scene.js'
@@ -21,6 +21,7 @@ import { Scene } from '@babylonjs/core/scene.js'
import { WATER_LEVEL, terrainHeight } from '@openvibe/content'
import { WATER_LEVEL, terrainHeight } from '@openvibe/content'
import { DEFAULT_MOVEMENT } from '@openvibe/gameplay'
import { DEFAULT_MOVEMENT } from '@openvibe/gameplay'
import { Sfx, footstepSurface, materialForVisual } from './audio/sfx.js'
import { Sfx, footstepSurface, materialForVisual } from './audio/sfx.js'
+import { HitChips } from './render/hitChips.js'
import { InteractionController } from './game/interactionController.js'
import { InteractionController } from './game/interactionController.js'
import { LocalPlayer } from './game/localPlayer.js'
import { LocalPlayer } from './game/localPlayer.js'
import { InputTracker } from './input/inputTracker.js'
import { InputTracker } from './input/inputTracker.js'
@@ -352,6 +353,7 @@ async function start(): Promise<void> {
@@ -352,6 +353,7 @@ async function start(): Promise<void> {
// Sounds. Browsers start audio only after a gesture: the first click or key.
// Sounds. Browsers start audio only after a gesture: the first click or key.
const sfx = new Sfx()
const sfx = new Sfx()
+ const chips = new HitChips(scene)
const unlockAudio = (): void => sfx.unlock()
const unlockAudio = (): void => sfx.unlock()
window.addEventListener('pointerdown', unlockAudio)
window.addEventListener('pointerdown', unlockAudio)
window.addEventListener('keydown', unlockAudio)
window.addEventListener('keydown', unlockAudio)
@@ -368,8 +370,12 @@ async function start(): Promise<void> {
@@ -368,8 +370,12 @@ async function start(): Promise<void> {
: target.def && content.item(target.def)?.health
: target.def && content.item(target.def)?.health
? 'wood'
? 'wood'
: null
: null
- // The blow lands mid-swing, after the whoosh starts.
- if (material) setTimeout(() => sfx.hit(material), 90)
+ // The blow lands mid-swing, after the whoosh starts: its sound, and what flies off.
+ if (material)
+ setTimeout(() => {
+ sfx.hit(material)
+ chips.burst(target.point, target.normal, material, player.eye)
+ }, 90)
}
}
let packCount = -1
let packCount = -1
state.events.on('inventory', () => {
state.events.on('inventory', () => {
@@ -646,6 +652,7 @@ async function start(): Promise<void> {
@@ -646,6 +652,7 @@ async function start(): Promise<void> {
}
}
wasGrounded = grounded
wasGrounded = grounded
sfx.update(elapsed, environment.daylight)
sfx.update(elapsed, environment.daylight)
+ chips.update(elapsed)
// Beams (modular: any weapon whose module declares firesBeam gets this
// Beams (modular: any weapon whose module declares firesBeam gets this
// muzzle-anchored beam pipeline). Mine fires whenever the trigger is
// muzzle-anchored beam pipeline). Mine fires whenever the trigger is
new file mode 100644
new file mode 100644
@@ -0,0 +1,72 @@
@@ -0,0 +1,72 @@
+/**
+ * Hit particles live in two thin-instanced meshes. What must hold: a hit
+ * throws its burst, the burst falls and is gone within a second, an empty
+ * pool draws nothing (a mesh with no thin instances would draw itself, a
+ * metre cube at the origin), and a hit on a person throws nothing.
+ */
+import { afterEach, beforeEach, describe, expect, it } from 'vitest'
+import { NullEngine } from '@babylonjs/core/Engines/nullEngine.js'
+import { Scene } from '@babylonjs/core/scene.js'
+import type { Mesh } from '@babylonjs/core/Meshes/mesh.js'
+import { HitChips } from './hitChips.js'
+
+let engine: NullEngine
+let scene: Scene
+beforeEach(() => {
+ engine = new NullEngine()
+ scene = new Scene(engine)
+})
+afterEach(() => {
+ scene.dispose()
+ engine.dispose()
+})
+const mesh = (name: string) => scene.getMeshByName(name) as Mesh
+const step = (chips: HitChips, seconds: number) => {
+ for (let t = 0; t < seconds; t += 1 / 60) chips.update(1 / 60)
+}
+
+describe('HitChips', () => {
+ it('draws nothing until something is hit', () => {
+ const chips = new HitChips(scene)
+ chips.update(1 / 60)
+ for (const name of ['fx:chips', 'fx:sparks']) {
+ expect(mesh(name).thinInstanceCount).toBe(0)
+ expect(mesh(name).isVisible).toBe(false)
+ }
+ })
+
+ it('throws wood chips off a tree that fall and are gone within a second', () => {
+ const chips = new HitChips(scene)
+ chips.burst({ x: 0, y: 1, z: 0 }, { x: 0, y: 0, z: -1 }, 'wood')
+ chips.update(1 / 60)
+ const m = mesh('fx:chips')
+ expect(m.thinInstanceCount).toBe(9)
+ expect(m.isVisible).toBe(true)
+ step(chips, 1.2)
+ expect(m.thinInstanceCount).toBe(0)
+ expect(m.isVisible).toBe(false)
+ })
+
+ it('throws sparks, not chips, off scrap', () => {
+ const chips = new HitChips(scene)
+ chips.burst({ x: 0, y: 1, z: 0 }, { x: 1, y: 0, z: 0 }, 'metal')
+ chips.update(1 / 60)
+ expect(mesh('fx:sparks').thinInstanceCount).toBe(10)
+ expect(mesh('fx:chips').thinInstanceCount).toBe(0)
+ })
+
+ it('throws nothing off a person', () => {
+ const chips = new HitChips(scene)
+ chips.burst({ x: 0, y: 1, z: 0 }, { x: 1, y: 0, z: 0 }, 'flesh')
+ chips.update(1 / 60)
+ expect(mesh('fx:chips').thinInstanceCount).toBe(0)
+ expect(mesh('fx:sparks').thinInstanceCount).toBe(0)
+ })
+
+ it('keeps at most 96 in flight, dropping the oldest', () => {
+ const chips = new HitChips(scene)
+ for (let i = 0; i < 20; i++) chips.burst({ x: 0, y: 1, z: 0 }, { x: 0, y: 1, z: 0 }, 'wood')
+ chips.update(1 / 60)
+ expect(mesh('fx:chips').thinInstanceCount).toBe(96)
+ })
+})
new file mode 100644
new file mode 100644
@@ -0,0 +1,214 @@
@@ -0,0 +1,214 @@
+import '@babylonjs/core/Meshes/thinInstanceMesh.js' // thinInstanceSetBuffer() needs its side effect
+import { Color3 } from '@babylonjs/core/Maths/math.color.js'
+import { Matrix, Quaternion, Vector3 } from '@babylonjs/core/Maths/math.vector.js'
+import { StandardMaterial } from '@babylonjs/core/Materials/standardMaterial.js'
+import { CreateBox } from '@babylonjs/core/Meshes/Builders/boxBuilder.js'
+import type { Mesh } from '@babylonjs/core/Meshes/mesh.js'
+import type { Scene } from '@babylonjs/core/scene.js'
+import type { HitMaterial } from '../audio/sfx.js'
+
+/**
+ * What flies off a hit: wood chips off a tree, grit off a rock, sparks off
+ * scrap, leaves off a bush. A hit used to change nothing on screen until the
+ * server's reply moved a count, so a swing felt like it went through.
+ *
+ * Two thin-instanced meshes (lit chips, unlit sparks) hold every particle:
+ * two draw calls however many are flying, nothing allocated per hit.
+ */
+
+const MAX = 96
+const GRAVITY = 12
+
+interface Particle {
+ pos: Vector3
+ vel: Vector3
+ axis: Vector3
+ angle: number
+ spin: number
+ size: number
+ age: number
+ life: number
+ gravity: number
+ color: [number, number, number]
+}
+
+interface Kind {
+ count: number
+ colors: string[]
+ spark?: boolean
+ speed: number
+ size: number
+ life: number
+ gravity: number
+}
+
+const KINDS: Record<Exclude<HitMaterial, 'flesh'>, Kind> = {
+ wood: {
+ count: 9,
+ colors: ['#8a5a2b', '#a87437', '#6d4c2a', '#c49a62'],
+ speed: 2,
+ size: 0.045,
+ life: 0.7,
+ gravity: 1,
+ },
+ stone: {
+ count: 8,
+ colors: ['#8d8d88', '#a3a39e', '#6f6f6a'],
+ speed: 2.3,
+ size: 0.035,
+ life: 0.55,
+ gravity: 1,
+ },
+ metal: {
+ count: 10,
+ colors: ['#ffd27a'],
+ spark: true,
+ speed: 4.5,
+ size: 0.025,
+ life: 0.35,
+ gravity: 0.6,
+ },
+ plant: {
+ count: 6,
+ colors: ['#4a7a3a', '#5d8f3f', '#7d9a45'],
+ speed: 1.2,
+ size: 0.045,
+ life: 0.9,
+ gravity: 0.3,
+ },
+}
+
+export class HitChips {
+ private readonly chips: Pool
+ private readonly sparks: Pool
+
+ constructor(scene: Scene) {
+ const chipMat = new StandardMaterial('fx:chips', scene)
+ chipMat.diffuseColor = Color3.White()
+ chipMat.specularColor = new Color3(0.05, 0.05, 0.05)
+ const sparkMat = new StandardMaterial('fx:sparks', scene)
+ sparkMat.disableLighting = true
+ sparkMat.emissiveColor = new Color3(1, 0.78, 0.35)
+ this.chips = new Pool(scene, 'fx:chips', chipMat, true)
+ this.sparks = new Pool(scene, 'fx:sparks', sparkMat, false)
+ }
+
+ /**
+ * Throws a burst off `point`, out to the sides and up from the surface
+ * `normal`. Nothing flies toward `eye` (the camera), and a hit right in
+ * front of it throws smaller pieces. Hits on people throw nothing.
+ */
+ burst(point: Vector3Like, normal: Vector3Like, material: HitMaterial, eye?: Vector3Like): void {
+ if (material === 'flesh') return
+ const kind = KINDS[material]
+ const pool = kind.spark ? this.sparks : this.chips
+ const n = new Vector3(normal.x, normal.y, normal.z)
+ if (n.lengthSquared() < 1e-6) n.set(0, 1, 0)
+ n.normalize()
+ const toEye = eye ? new Vector3(eye.x - point.x, eye.y - point.y, eye.z - point.z) : null
+ const near = toEye ? Math.min(1, toEye.length() / 1.5) : 1
+ toEye?.normalize()
+ for (let i = 0; i < kind.count; i++) {
+ const hex = kind.colors[Math.floor(Math.random() * kind.colors.length)]!
+ const c = Color3.FromHexString(hex)
+ const spread = new Vector3(
+ Math.random() - 0.5,
+ Math.random() * 0.8,
+ Math.random() - 0.5,
+ ).scaleInPlace(2)
+ const speed = kind.speed * (0.6 + Math.random() * 0.6)
+ const vel = n
+ .scale(0.45)
+ .addInPlace(spread)
+ .normalize()
+ .scaleInPlace(speed)
+ .addInPlace(new Vector3(0, 1.2, 0))
+ if (toEye) {
+ const toward = Vector3.Dot(vel, toEye)
+ if (toward > 0) vel.subtractInPlace(toEye.scale(toward))
+ }
+ pool.add({
+ pos: new Vector3(point.x, point.y, point.z).addInPlace(n.scale(0.05)),
+ vel,
+ axis: new Vector3(
+ Math.random() - 0.5,
+ Math.random() - 0.5,
+ Math.random() - 0.5,
+ ).normalize(),
+ angle: Math.random() * Math.PI,
+ spin: (Math.random() - 0.5) * 18,
+ size: kind.size * (0.6 + Math.random() * 0.8) * Math.max(0.35, near),
+ age: 0,
+ life: kind.life * (0.7 + Math.random() * 0.6),
+ gravity: GRAVITY * kind.gravity,
+ color: [c.r, c.g, c.b],
+ })
+ }
+ }
+
+ update(dt: number): void {
+ this.chips.update(dt)
+ this.sparks.update(dt)
+ }
+}
+
+interface Vector3Like {
+ x: number
+ y: number
+ z: number
+}
+
+/** One thin-instanced cube and the particles it draws, the live ones packed at the front. */
+class Pool {
+ private readonly mesh: Mesh
+ private readonly live: Particle[] = []
+ private readonly matrices = new Float32Array(MAX * 16)
+ private readonly colors: Float32Array | null
+ private readonly m = new Matrix()
+ private readonly q = new Quaternion()
+ private readonly s = new Vector3()
+
+ constructor(scene: Scene, name: string, material: StandardMaterial, colored: boolean) {
+ this.mesh = CreateBox(name, { size: 1 }, scene)
+ this.mesh.material = material
+ this.mesh.isPickable = false
+ this.mesh.alwaysSelectAsActiveMesh = true // particles fly outside the unit box's bounds
+ this.mesh.thinInstanceSetBuffer('matrix', this.matrices, 16, false)
+ this.colors = colored ? new Float32Array(MAX * 4).fill(1) : null
+ if (this.colors) this.mesh.thinInstanceSetBuffer('color', this.colors, 4, false)
+ this.mesh.thinInstanceCount = 0
+ // With no thin instances a mesh draws itself: a metre cube at the origin.
+ this.mesh.isVisible = false
+ }
+
+ add(p: Particle): void {
+ if (this.live.length >= MAX) this.live.shift()
+ this.live.push(p)
+ }
+
+ update(dt: number): void {
+ if (this.live.length === 0 && this.mesh.thinInstanceCount === 0) return
+ let w = 0
+ for (const p of this.live) {
+ p.age += dt
+ if (p.age >= p.life) continue
+ p.vel.y -= p.gravity * dt
+ p.pos.addInPlaceFromFloats(p.vel.x * dt, p.vel.y * dt, p.vel.z * dt)
+ p.angle += p.spin * dt
+ // Shrink away over the last third of the life.
+ const left = 1 - p.age / p.life
+ const size = p.size * Math.min(1, left * 3)
+ Quaternion.RotationAxisToRef(p.axis, p.angle, this.q)
+ this.s.setAll(size)
+ Matrix.ComposeToRef(this.s, this.q, p.pos, this.m)
+ this.m.copyToArray(this.matrices, w * 16)
+ if (this.colors) this.colors.set([p.color[0], p.color[1], p.color[2], 1], w * 4)
+ this.live[w++] = p
+ }
+ this.live.length = w
+ this.mesh.thinInstanceCount = w
+ this.mesh.isVisible = w > 0
+ this.mesh.thinInstanceBufferUpdated('matrix')
+ if (this.colors) this.mesh.thinInstanceBufferUpdated('color')
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,3 @@
@@ -0,0 +1,3 @@
+// file: 'apps/client/src/render/hitChips.test.ts'
+// covers: 'apps/client/src/render'
+// Generated by node test/run.js --index; do not edit.