Bauen auf Voxel-Terrain in Babylon.js · Teil 10 von 10 der Serie
Teil 10: Staub und Klang
Gib dem Einsturz Gewicht: Staubwolken in der Farbe des Materials, wo Stücke landen und Blöcke zerbrechen, und dumpfe Schläge, Krachen und Klopfen, mit der Web Audio API aus Rauschen und einem fallenden Ton gemacht, lauter und tiefer bei schwereren, härteren Treffern, ohne Sounddateien.

Quest-Ziele
- Den härtesten Aufprall eines fallenden Stücks finden, und wo er war
- Staub mit Partikeln von Babylon.js aufwirbeln, aus einer in Code gemalten Textur
- Dumpfe Schläge, Krachen und Klopfen aus Rauschen, Filtern und Hüllkurven mit der Web Audio API machen
- Einen Klang mit der Entfernung leiser machen und ihn links oder rechts platzieren
- Den Ton beim ersten Klick starten, wie es Browser verlangen
Die Stücke fallen, überschlagen sich und zerschlagen Dinge, und das ohne einen Laut, ohne ein Staubkorn: wie ein Video ohne Ton. Dieser letzte Teil fügt hinzu, was einen Einsturz schwer wirken lässt. Wo ein Stück landet, steigt eine Staubwolke auf, größer bei einem härteren Treffer und einem größeren Stück. Ein Block, der zerbricht, gibt ein Krachen und eine Wolke in seiner eigenen Farbe. Landungen dröhnen, tiefer bei schwereren Stücken. Blöcke setzen und meißeln bekommt ein trockenes Klopfen.
Wie bei den Texturen wird nichts geladen: Die Textur des Staubs wird in Code gemalt, und jeder Klang wird aus Rauschen und einem Ton gemacht, geformt von einem Filter und einer Hüllkurve.
Aufpralle
Der Körper rechnet aus, wie schnell sich jeder Kontaktpunkt seiner Oberfläche nähert. Ein Punkt, der sich schneller als 2 m/s nähert, ist ein Aufprall. Der Körper behält den härtesten, seit er zuletzt gefragt wurde, und wo er war. Ergänze in src/body.ts das Tempo bei den Konstanten:
/** A point closing in on a surface faster than this, in meters per second, is an impact. */
const IMPACT_SPEED = 2;Ein Feld in Body:
/** The hardest impact since someone last read and cleared it: where, and how fast. */
impact: { point: Vector3; speed: number } | null = null;Und in contacts(), nach den Treffern:
if (closing > Math.max(this.impact?.speed ?? 0, IMPACT_SPEED)) this.impact = { point, speed: closing };Eine Landung ergibt ein paar Aufpralle, einen pro Abprallen, und ein Stück meldet höchstens einen pro Frame. Ergänze in src/pieces.ts die Felder von Pieces:
/** Called when a falling piece hits something hard: where, how fast, and the piece. */
onImpact: (point: Vector3, speed: number, piece: Piece) => void = () => {};Und in update(), nachdem die Treffer weitergegeben sind:
if (piece.body.impact) this.onImpact(piece.body.impact.point, piece.body.impact.speed, piece);
piece.body.impact = null;Auch die Werkzeuge sagen, was sie getan haben. Ergänze in src/buildTool.ts die Felder von BlockTool:
/** Called with every block the tool places or breaks. */
onEdit: (action: 'place' | 'break', block: Block) => void = () => {};Und ruf es dort auf, wo das Werkzeug einen Block abbaut oder setzt, am Ende von update():
if (action === 'break' && aim.kind === 'block') {
this.store.remove(aim.block);
this.onEdit('break', aim.block);
}
if (action === 'place' && fits) {
this.store.add(block);
this.onEdit('place', block);
}Der Meißel sagt, wann er einen Block geändert hat: Die Revision des Speichers zählt jede Änderung, und ein Schnitt, der nichts ändert, wie das Runden einer runden Kante, lässt sie, wie sie war. Ergänze in src/chiselTool.ts seine Felder:
/** Called with every block the chisel changes. */
onCut: (block: Block) => void = () => {};Und in update(), um die beiden Zeilen, die den Block umformen; importiere den Typ Block:
const before = this.store.revision;
if (this.clicked === 'cut' && possible) this.store.reshape(block, cut.cuts, cut.slope);
if (this.clicked === 'sharpen') this.store.reshape(block, block.cuts & ~mask, block.slope);
if (this.store.revision !== before) this.onCut(block);Staub
Babylon.js hat ein ParticleSystem: eine Wolke aus kleinen Billboards, jedes eine Weile lebend, sich bewegend und verblassend. Eine Staubwolke ist eines, für den Anlass gemacht: Es schickt alle seine Teilchen auf einmal los, aus einer Kugel um den Punkt, erst hinauf und hinaus, dann zieht die Schwerkraft sie langsam herunter. Es räumt sich selbst ab, wenn sie weg sind.
Die Teilchen brauchen eine Textur: einen weichen runden Fleck, weiß, zum Rand hin durchscheinend werdend, einmal in eine Textur von 32 × 32 gemalt. Seine Farbe kommt vom Partikelsystem, die Farbe des Materials halb mit Grau gemischt: Staub ist blasser als das Ding, von dem er kommt. Je größer die Wolke, desto mehr Teilchen, desto größer und desto schneller. Lege src/dust.ts an:
import {
Color3,
Color4,
Constants,
ParticleSystem,
RawTexture,
Texture,
Vector3,
type Scene,
} from '@babylonjs/core/pure';
/** Pixels along each side of the dust's texture. */
const SIZE = 32;
/** A soft round spot, painted once: white, and see-through toward its rim. */
function paintSpot(scene: Scene): RawTexture {
const data = new Uint8Array(SIZE * SIZE * 4);
for (let y = 0; y < SIZE; y += 1) {
for (let x = 0; x < SIZE; x += 1) {
const d = Math.hypot(x + 0.5 - SIZE / 2, y + 0.5 - SIZE / 2) / (SIZE / 2);
const alpha = Math.max(1 - d, 0) ** 1.5;
data.set([255, 255, 255, alpha * 255], (y * SIZE + x) * 4);
}
}
return new RawTexture(data, SIZE, SIZE, Constants.TEXTUREFORMAT_RGBA, scene, false, false, Texture.BILINEAR_SAMPLINGMODE);
}
/** Puffs of dust, each a little particle system that goes away when it is done. */
export class Dust {
private readonly texture: RawTexture;
private readonly scene: Scene;
constructor(scene: Scene) {
this.scene = scene;
this.texture = paintSpot(scene);
}
/**
* A puff at a point, in a material's color mixed with grey. `amount` from 0 to 1 says
* how big: more, larger and faster bits for a harder hit.
*/
puff(point: Vector3, color: string, amount: number): void {
const strength = Math.min(Math.max(amount, 0), 1);
const count = Math.round(8 + 56 * strength);
const system = new ParticleSystem('dust', count, this.scene);
system.particleTexture = this.texture;
system.emitter = point.clone();
system.createSphereEmitter(0.3 + strength * 0.7);
const tint = Color3.Lerp(Color3.FromHexString(color), new Color3(0.75, 0.72, 0.7), 0.5);
system.color1 = new Color4(tint.r, tint.g, tint.b, 0.8);
system.color2 = new Color4(tint.r * 0.8, tint.g * 0.8, tint.b * 0.8, 0.65);
system.colorDead = new Color4(tint.r, tint.g, tint.b, 0);
system.minSize = 0.2 + strength * 0.3;
system.maxSize = 0.5 + strength * 0.9;
system.minLifeTime = 0.6;
system.maxLifeTime = 1.4 + strength;
system.minEmitPower = 0.3;
system.maxEmitPower = 1 + strength * 3;
system.minAngularSpeed = -1;
system.maxAngularSpeed = 1;
// Up and out at first, then the bits sink slowly.
system.gravity = new Vector3(0, -1.5, 0);
system.blendMode = ParticleSystem.BLENDMODE_STANDARD;
system.manualEmitCount = count;
system.targetStopDuration = 3;
system.disposeOnStop = true;
system.start();
}
}Partikel müssen registriert werden, wie der Rest von Babylon.js in diesem Projekt: das System selbst und die Szenen-Komponente, die es zeichnet. Ergänze in src/main.ts die Registrierungen und importiere RegisterParticleSystem und RegisterParticleSystemComponent:
// For the dust.
RegisterParticleSystem();
RegisterParticleSystemComponent();Klang aus Rauschen
Die Web Audio API baut Klang aus Knoten, in einem Graphen verbunden: eine Quelle, Filter, ein Gain, der die Lautstärke setzt, und am Ende die Lautsprecher. Die Klänge hier beginnen alle mit derselben Sekunde weißen Rauschens, eine Zufallszahl pro Sample, einmal gemacht. Rauschen enthält jede Tonhöhe auf einmal, und ein Filter wählt einen Teil davon:
Ein dumpfer Schlag ist Rauschen durch einen Tiefpass, sodass nur der tiefe Teil durchkommt, mit einem Ton darunter, der beim Verklingen um eine Oktave fällt: der Körper eines schweren Dings, das landet. Je schwerer das Stück, desto tiefer der Ton.
Ein Krachen ist Rauschen durch einen Hochpass, das helle Zischen von Splittern, mit etwas von der Mitte für den Körper des Bruchs. Es ist in einer Viertelsekunde vorbei.
Ein Klopfen ist ein kurzer Stoß Rauschen durch einen Bandpass: ein trockenes Klicken. Höher für den Meißel als für einen gesetzten Block.
Was Rauschen wie einen Schlag klingen lässt, ist die Hüllkurve: Der Gain beginnt sofort mit voller Lautstärke und fällt entlang einer exponentiellen Rampe ab. Das Ohr hört den scharfen Beginn als den Schlag und das Abfallen als das Material, das ausklingt.
Jeder Klang wird mit der Entfernung leiser, bei 8 m halbiert, und wird von einem Stereo-Panner zwischen die Ohren gesetzt: Wie weit der Klang rechts von der Kamera liegt, entscheidet, wie viel davon der rechte Lautsprecher bekommt. Ein Einsturz kann Dutzende Klänge auf einmal machen, was nur zu Brei wird; höchstens sechs beginnen in jeder Zehntelsekunde.
Browser lassen eine Seite erst Klang abspielen, nachdem der Nutzer etwas auf ihr getan hat. Also wird der Audio-Kontext beim ersten Klick gemacht oder fortgesetzt: dem, der die Maus einfängt. Lege src/sounds.ts an:
import { Vector3, type Camera } from '@babylonjs/core/pure';
/** How many sounds may start in one tenth of a second: a collapse makes a lot of them. */
const MOST_AT_ONCE = 6;
/**
* Thuds, cracks and knocks, made with the Web Audio API from noise and a falling tone,
* no sound files. Each goes through a filter that gives it its color and an envelope that
* makes it start at once and fade out.
*/
export class Sounds {
private context: AudioContext | null = null;
private noise: AudioBuffer | null = null;
private recent: number[] = [];
private readonly camera: Camera;
constructor(camera: Camera) {
this.camera = camera;
}
/** Browsers let a page play sound only after a click: called on the first one. */
start(): void {
if (!this.context) {
this.context = new AudioContext();
// A second of white noise, which every sound plays a piece of.
this.noise = this.context.createBuffer(1, this.context.sampleRate, this.context.sampleRate);
const samples = this.noise.getChannelData(0);
for (let i = 0; i < samples.length; i += 1) samples[i] = Math.random() * 2 - 1;
}
void this.context.resume();
}
/** Something heavy landing: deep noise and a tone falling from `pitch`, `strength` 0 to 1. */
thud(at: Vector3, strength: number, pitch: number): void {
this.play(at, strength, 0.6, (context, out, time) => {
this.noiseThrough(context, out, time, 'lowpass', pitch * 3, 0.5);
const tone = context.createOscillator();
tone.frequency.setValueAtTime(pitch, time);
tone.frequency.exponentialRampToValueAtTime(pitch * 0.5, time + 0.3);
tone.connect(out);
tone.start(time);
tone.stop(time + 0.6);
});
}
/** A block breaking: a short, bright crackle. */
crack(at: Vector3, strength: number): void {
this.play(at, strength, 0.25, (context, out, time) => {
this.noiseThrough(context, out, time, 'highpass', 1800, 0.25);
this.noiseThrough(context, out, time, 'bandpass', 600, 0.15);
});
}
/** A block put down or chiseled: a dry knock. */
knock(at: Vector3, pitch = 700): void {
this.play(at, 0.4, 0.08, (context, out, time) => this.noiseThrough(context, out, time, 'bandpass', pitch, 0.08));
}
/** Plays a piece of the noise through a filter into `out`. */
private noiseThrough(
context: AudioContext,
out: AudioNode,
time: number,
type: BiquadFilterType,
frequency: number,
length: number,
): void {
const source = context.createBufferSource();
source.buffer = this.noise;
const filter = context.createBiquadFilter();
filter.type = type;
filter.frequency.value = frequency;
source.connect(filter).connect(out);
source.start(time, Math.random() * 0.5, length);
}
/**
* Starts a sound at a point: softer the further away it is, more in the left or right
* ear the more it is to that side of the camera. `make` connects its parts to `out`,
* whose loudness falls off over `length` seconds.
*/
private play(
at: Vector3,
strength: number,
length: number,
make: (context: AudioContext, out: AudioNode, time: number) => void,
): void {
const context = this.context;
if (!context || context.state !== 'running') return;
const now = context.currentTime;
this.recent = this.recent.filter((time) => now - time < 0.1);
if (this.recent.length >= MOST_AT_ONCE) return;
this.recent.push(now);
const offset = at.subtract(this.camera.globalPosition);
const distance = offset.length();
const volume = Math.min(Math.max(strength, 0), 1) / (1 + distance / 8);
if (volume < 0.01) return;
const right = this.camera.getDirection(Vector3.Right());
const panner = context.createStereoPanner();
panner.pan.value = distance > 0 ? Vector3.Dot(offset, right) / distance : 0;
const gain = context.createGain();
gain.gain.setValueAtTime(volume, now);
gain.gain.exponentialRampToValueAtTime(0.001, now + length);
gain.connect(panner).connect(context.destination);
make(context, gain, now);
}
}Jeder Klang spielt ein Stück des Rauschens von einer zufälligen Stelle darin, damit keine zwei ganz gleich klingen. Der Ton und die Rauschquellen hören von selbst auf; einmal gestoppt, verwirft der Browser die Knoten.
Alles verbinden
Erzeuge in src/main.ts nach den Stücken den Staub und die Klänge, mit einem Helfer für die Mitte eines Blocks und einem für einen Block, der zerbricht. Eine Landung staubt und dröhnt:
// Dust and sound where pieces land, blocks break and blocks go down.
const dust = new Dust(scene);
const sounds = new Sounds(camera);
/** The middle of a block of the grid, in the world. */
const middleOf = ({ x, y, z, size }: Block) => new Vector3(x + size / 2, y + size / 2, z + size / 2).scaleInPlace(CELL);
/** A block breaks: a crack and a puff in its color. */
const crumble = (point: Vector3, block: Block, strength: number) => {
dust.puff(point, MATERIALS[block.material].color, strength);
sounds.crack(point, strength);
};
// A landing: the harder and the bigger the piece, the more dust, and the deeper the thud.
pieces.onImpact = (point, speed, piece) => {
const strength = Math.min((speed - 2) / 8, 1);
const tonnes = piece.body.mass / 1000;
dust.puff(point, MATERIALS[piece.blocks[0].material].color, strength * Math.min(0.3 + tonnes / 10, 1));
sounds.thud(point, strength, Math.min(Math.max(110 / Math.cbrt(tonnes), 40), 140));
};Ein Block, den ein fallendes Stück zerbricht, zerbröselt:
pieces.onHit = (block, speed, mass) => {
if (!damage.hit(block, speed, mass)) return;
blocks.store.remove(block);
crumble(middleOf(block), block, 0.8);
broken += 1;
};Ebenso Blöcke, die von Schutt abgebrochen werden, und Blöcke, die du abbaust, und Setzen und Meißeln klopfen. Ersetze den Handler des Schutts nach dem Erzeugen der Werkzeuge:
blockTool.breakRubble = (piece, block) => {
crumble(piece.body.toWorld(middleOf(block)), block, 0.4);
pieces.breakBlock(piece, block);
};
blockTool.onEdit = (action, block) => {
if (action === 'place') sounds.knock(middleOf(block));
else crumble(middleOf(block), block, 0.4);
};
chiselTool.onCut = (block) => sounds.knock(middleOf(block), 1400);Und starte den Klang beim ersten Klick:
canvas.addEventListener('pointerdown', (event) => {
sounds.start();Importiere Dust aus ./dust.ts, Sounds aus ./sounds.ts und den Typ Block aus ./blocks.ts.
Ausprobieren
Bau den Balken neben der Säule ab: Die Kupferkugel landet mit einem dumpfen Schlag auf dem Tisch, die Bretter krachen, und eine kupferfarbene Staubwolke steigt auf, wo sie traf. Schlag den Fuß der Säule heraus und hör zu, wie sie herunterkommt. Bau ein Haus, mit Dach, und nimm es von unten auseinander.
Wie es weitergehen kann
Das war die Serie: Blöcke in drei Größen auf dem glatten Terrain, gemalte Materialien, vom Höhlenlicht des Terrains beleuchtet, Gehen auf Blöcken, ein Meißel, der rundet und schrägt, die Regeln dafür, was einen Block hält, starre Körper, die fallen und als Schutt landen, Kettenreaktionen, und Staub und Klang. Ein paar Ideen, wie es weitergehen kann:
Speichern: Die Blöcke sind eine Liste kleiner Einträge, und die Edits des Terrains eine Liste von Kugeln. Behalte beide in IndexedDB, wie die Terrain-Serie es für die Edits vorgeschlagen hat, und den Schutt auch, mit seiner Lage.
Festigkeit mit Gewicht: Der Halt aus Teil 6 ist alles oder nichts. Ein Balken könnte das Gewicht zählen, das er trägt, und brechen, wenn es zu viel wird, sodass eine lange, dünne Brücke unter schwerer Last durchhängt und nachgibt.
Stücke, die einander treffen: Fallende Stücke treffen auf Terrain, Blöcke und Schutt, aber nicht aufeinander. Die Punkte jedes fallenden Stücks gegen die anderen zu testen und den Stoß beiden zu geben, ist ein Schritt hin zu einer vollen Physik-Engine.
Schlafende Inseln: Ein großer Einsturz macht viele Stücke. Stücke, die sich berühren, könnten zusammen gelöst werden, was Stapel aus Schutt vom Zittern abhält, und die ruhigen könnten schlafen, bis sie etwas berührt.
Viele Spieler: Alles hier wird auf einem Rechner entschieden. Mit einem Server, der entscheidet, was fällt, und jedem Client, der dieselben Stürze abspielt, geschieht derselbe Einsturz für alle.
Der Code jedes Teils steckt in den Demos, die hier in der Serie direkt spielbar sind. Viel Spaß beim Bauen, und beim Wieder-Auseinandernehmen.
