|
| 1 | +import Phaser from "phaser"; |
| 2 | +import { |
| 3 | + DisplacementBurstModel, |
| 4 | + type ProjectedDisplacementBurst, |
| 5 | + projectBurstToPostFx, |
| 6 | +} from "./ripple"; |
| 7 | + |
| 8 | +const DISPLACEMENT_PIPELINE_KEY = "DashDisplacementPipeline"; |
| 9 | +const MAX_SHADER_BURSTS = 4; |
| 10 | +const NEAREST_RENDER_TARGET_FILTER = 1; |
| 11 | + |
| 12 | +const DASH_DISPLACEMENT_FRAG = ` |
| 13 | +precision mediump float; |
| 14 | +
|
| 15 | +uniform sampler2D uMainSampler; |
| 16 | +uniform int uBurstCount; |
| 17 | +uniform vec2 uResolution; |
| 18 | +uniform vec2 uCenters[${MAX_SHADER_BURSTS}]; |
| 19 | +uniform float uRadii[${MAX_SHADER_BURSTS}]; |
| 20 | +uniform float uRingWidths[${MAX_SHADER_BURSTS}]; |
| 21 | +uniform float uAmplitudes[${MAX_SHADER_BURSTS}]; |
| 22 | +uniform float uStrengths[${MAX_SHADER_BURSTS}]; |
| 23 | +
|
| 24 | +varying vec2 outTexCoord; |
| 25 | +
|
| 26 | +void main() { |
| 27 | + vec2 pixel = outTexCoord * uResolution; |
| 28 | + vec2 offset = vec2(0.0); |
| 29 | +
|
| 30 | + for (int i = 0; i < ${MAX_SHADER_BURSTS}; i++) { |
| 31 | + if (i >= uBurstCount) { |
| 32 | + break; |
| 33 | + } |
| 34 | +
|
| 35 | + vec2 delta = pixel - uCenters[i]; |
| 36 | + float dist = length(delta); |
| 37 | + float ringDistance = abs(dist - uRadii[i]); |
| 38 | + float envelope = 1.0 - smoothstep(0.0, uRingWidths[i], ringDistance); |
| 39 | + float wave = sin((dist - uRadii[i]) * 1.8) * envelope; |
| 40 | + vec2 dir = dist > 0.001 ? delta / dist : vec2(0.0); |
| 41 | + offset += dir * wave * uAmplitudes[i] * uStrengths[i]; |
| 42 | + } |
| 43 | +
|
| 44 | + vec2 displacedCoord = clamp(outTexCoord - offset / uResolution, vec2(0.001), vec2(0.999)); |
| 45 | + gl_FragColor = texture2D(uMainSampler, displacedCoord); |
| 46 | +} |
| 47 | +`; |
| 48 | + |
| 49 | +export class DashDisplacementPipeline extends Phaser.Renderer.WebGL.Pipelines.PostFXPipeline { |
| 50 | + private burstCount = 0; |
| 51 | + private resolutionX = 1; |
| 52 | + private resolutionY = 1; |
| 53 | + private readonly centers = new Float32Array(MAX_SHADER_BURSTS * 2); |
| 54 | + private readonly radii = new Float32Array(MAX_SHADER_BURSTS); |
| 55 | + private readonly ringWidths = new Float32Array(MAX_SHADER_BURSTS); |
| 56 | + private readonly amplitudes = new Float32Array(MAX_SHADER_BURSTS); |
| 57 | + private readonly strengths = new Float32Array(MAX_SHADER_BURSTS); |
| 58 | + |
| 59 | + constructor(game: Phaser.Game) { |
| 60 | + super({ |
| 61 | + game, |
| 62 | + fragShader: DASH_DISPLACEMENT_FRAG, |
| 63 | + renderTarget: [{ minFilter: NEAREST_RENDER_TARGET_FILTER, autoResize: true }], |
| 64 | + }); |
| 65 | + } |
| 66 | + |
| 67 | + setBursts(bursts: readonly ProjectedDisplacementBurst[], width: number, height: number): void { |
| 68 | + this.burstCount = Math.min(bursts.length, MAX_SHADER_BURSTS); |
| 69 | + this.resolutionX = Math.max(1, width); |
| 70 | + this.resolutionY = Math.max(1, height); |
| 71 | + this.centers.fill(0); |
| 72 | + this.radii.fill(0); |
| 73 | + this.ringWidths.fill(1); |
| 74 | + this.amplitudes.fill(0); |
| 75 | + this.strengths.fill(0); |
| 76 | + |
| 77 | + for (let i = 0; i < this.burstCount; i++) { |
| 78 | + const burst = bursts[i]; |
| 79 | + this.centers[i * 2] = burst.screenX; |
| 80 | + this.centers[i * 2 + 1] = burst.screenY; |
| 81 | + this.radii[i] = burst.radius; |
| 82 | + this.ringWidths[i] = burst.ringWidth; |
| 83 | + this.amplitudes[i] = burst.amplitude; |
| 84 | + this.strengths[i] = burst.strength; |
| 85 | + } |
| 86 | + |
| 87 | + // Keep the whole camera on one render path so dash start/end does not |
| 88 | + // toggle full-viewport framebuffer sampling and visibly change sharp edges. |
| 89 | + this.active = true; |
| 90 | + } |
| 91 | + |
| 92 | + onDraw(renderTarget: Phaser.Renderer.WebGL.RenderTarget): void { |
| 93 | + this.set1i("uBurstCount", this.burstCount); |
| 94 | + this.set2f("uResolution", this.resolutionX, this.resolutionY); |
| 95 | + this.set2fv("uCenters", this.centers); |
| 96 | + this.set1fv("uRadii", this.radii); |
| 97 | + this.set1fv("uRingWidths", this.ringWidths); |
| 98 | + this.set1fv("uAmplitudes", this.amplitudes); |
| 99 | + this.set1fv("uStrengths", this.strengths); |
| 100 | + this.bindAndDraw(renderTarget); |
| 101 | + } |
| 102 | +} |
| 103 | + |
| 104 | +export class DisplacementSystem { |
| 105 | + private readonly model = new DisplacementBurstModel(); |
| 106 | + private readonly camera: Phaser.Cameras.Scene2D.Camera; |
| 107 | + private readonly pipeline: DashDisplacementPipeline | null; |
| 108 | + |
| 109 | + constructor(private readonly scene: Phaser.Scene, camera = scene.cameras.main) { |
| 110 | + this.camera = camera; |
| 111 | + this.pipeline = this.installPipeline(); |
| 112 | + this.syncPipeline(); |
| 113 | + } |
| 114 | + |
| 115 | + addBurst(x: number, y: number): void { |
| 116 | + if (this.pipeline === null) return; |
| 117 | + |
| 118 | + this.model.addBurst(x, y); |
| 119 | + this.syncPipeline(); |
| 120 | + } |
| 121 | + |
| 122 | + update(dt: number): void { |
| 123 | + if (this.pipeline === null) return; |
| 124 | + |
| 125 | + this.model.update(dt); |
| 126 | + this.syncPipeline(); |
| 127 | + } |
| 128 | + |
| 129 | + clear(): void { |
| 130 | + this.model.clear(); |
| 131 | + this.syncPipeline(); |
| 132 | + } |
| 133 | + |
| 134 | + destroy(): void { |
| 135 | + this.clear(); |
| 136 | + if (this.pipeline !== null) { |
| 137 | + this.camera.removePostPipeline(DISPLACEMENT_PIPELINE_KEY); |
| 138 | + } |
| 139 | + } |
| 140 | + |
| 141 | + private installPipeline(): DashDisplacementPipeline | null { |
| 142 | + const renderer = this.scene.game.renderer; |
| 143 | + if (renderer.type !== Phaser.WEBGL || !("pipelines" in renderer)) { |
| 144 | + return null; |
| 145 | + } |
| 146 | + |
| 147 | + renderer.pipelines.addPostPipeline(DISPLACEMENT_PIPELINE_KEY, DashDisplacementPipeline); |
| 148 | + this.camera.setPostPipeline(DISPLACEMENT_PIPELINE_KEY); |
| 149 | + const pipeline = this.camera.getPostPipeline(DISPLACEMENT_PIPELINE_KEY); |
| 150 | + return Array.isArray(pipeline) |
| 151 | + ? (pipeline[0] as DashDisplacementPipeline | undefined) ?? null |
| 152 | + : (pipeline as DashDisplacementPipeline | null); |
| 153 | + } |
| 154 | + |
| 155 | + private syncPipeline(): void { |
| 156 | + if (this.pipeline === null) return; |
| 157 | + |
| 158 | + const bursts = this.model.shaderBursts().map((burst) => projectBurstToPostFx(burst, this.camera)); |
| 159 | + |
| 160 | + this.pipeline.setBursts(bursts, this.camera.width, this.camera.height); |
| 161 | + } |
| 162 | +} |
0 commit comments