feat(glass): add selectable dynamics modes (#639)

This commit is contained in:
InfinityPacer
2026-08-04 16:34:39 +08:00
committed by GitHub
parent 084b950fec
commit a545f88b3c
17 changed files with 3073 additions and 379 deletions

View File

@@ -0,0 +1,187 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createGlassFluidDynamics, GLASS_FLUID_FIELD_FRAGMENT_SHADER } from '@/rendering/glass/glassFluidDynamics'
class FakeVector2 {
constructor(
public x = 0,
public y = 0,
) {}
set(x: number, y: number) {
this.x = x
this.y = y
return this
}
}
class FakeRenderTarget {
static instances: FakeRenderTarget[] = []
readonly dispose = vi.fn()
height: number
readonly setSize = vi.fn((width: number, height: number) => {
this.width = width
this.height = height
})
readonly texture = {}
width: number
constructor(
width: number,
height: number,
readonly options: Record<string, unknown>,
) {
this.height = height
this.width = width
FakeRenderTarget.instances.push(this)
}
}
class FakeShaderMaterial {
static instances: FakeShaderMaterial[] = []
readonly dispose = vi.fn()
readonly fragmentShader: string
readonly uniforms: Record<string, { value: unknown }>
constructor(options: { fragmentShader: string; uniforms: Record<string, { value: unknown }> }) {
this.fragmentShader = options.fragmentShader
this.uniforms = options.uniforms
FakeShaderMaterial.instances.push(this)
}
}
class FakeScene {
readonly children: FakeMesh[] = []
add(mesh: FakeMesh) {
this.children.push(mesh)
}
}
class FakeMesh {
frustumCulled = true
constructor(
readonly geometry: unknown,
readonly material: FakeShaderMaterial,
) {}
}
function createFluidHarness() {
let currentTarget: FakeRenderTarget | null = null
const pointer = new FakeVector2(0.25, 0.75)
const velocity = new FakeVector2(0.1, -0.2)
const renderer = {
render: vi.fn(),
setRenderTarget: vi.fn((target: FakeRenderTarget | null) => {
currentTarget = target
}),
setScissorTest: vi.fn(),
}
const three = {
LinearFilter: 1001,
Mesh: FakeMesh,
Scene: FakeScene,
ShaderMaterial: FakeShaderMaterial,
Vector2: FakeVector2,
WebGLRenderTarget: FakeRenderTarget,
} as unknown as typeof import('three')
return {
create: () =>
createGlassFluidDynamics({
camera: {} as never,
geometry: {} as never,
pointer: pointer as never,
renderer: renderer as never,
three,
velocity: velocity as never,
}),
getCurrentTarget: () => currentTarget,
pointer,
renderer,
velocity,
}
}
beforeEach(() => {
FakeRenderTarget.instances = []
FakeShaderMaterial.instances = []
})
describe('glass fluid dynamics', () => {
it('owns exactly one two-target field and reuses the shared pointer vectors', () => {
const harness = createFluidHarness()
const dynamics = harness.create()
const material = FakeShaderMaterial.instances[0]
expect(FakeRenderTarget.instances).toHaveLength(2)
expect(FakeRenderTarget.instances.map(target => target.options)).toEqual([
{
depthBuffer: false,
magFilter: 1001,
minFilter: 1001,
stencilBuffer: false,
},
{
depthBuffer: false,
magFilter: 1001,
minFilter: 1001,
stencilBuffer: false,
},
])
expect(material.fragmentShader).toBe(GLASS_FLUID_FIELD_FRAGMENT_SHADER)
expect(material.uniforms.uPointer.value).toBe(harness.pointer)
expect(material.uniforms.uVelocity.value).toBe(harness.velocity)
dynamics.dispose()
})
it('resizes, advances, swaps and clears its private temporal field', () => {
const harness = createFluidHarness()
const dynamics = harness.create()
const material = FakeShaderMaterial.instances[0]
const [firstTarget, secondTarget] = FakeRenderTarget.instances
dynamics.resize(800, 600, 1200, 600)
expect(FakeRenderTarget.instances.map(target => [target.width, target.height])).toEqual([
[200, 150],
[200, 150],
])
expect(material.uniforms.uTexelSize.value).toMatchObject({ x: 1 / 200, y: 1 / 150 })
expect(material.uniforms.uViewportAspect.value).toBe(2)
dynamics.setFrameParameters(0.8, 0.6)
const texture = dynamics.step()
expect(harness.renderer.setScissorTest).toHaveBeenCalledWith(false)
expect(material.uniforms.uPrevious.value).toBe(firstTarget.texture)
expect(harness.renderer.setRenderTarget.mock.calls).toEqual([[secondTarget], [null]])
expect(harness.renderer.render).toHaveBeenCalledOnce()
expect(harness.getCurrentTarget()).toBeNull()
expect(texture).toBe(secondTarget.texture)
dynamics.finishFrame()
expect(material.uniforms.uInjection.value).toBe(0)
expect(material.uniforms.uDecay.value).toBe(0.8)
dynamics.clearInput()
expect(material.uniforms.uDecay.value).toBe(0)
expect(material.uniforms.uInjection.value).toBe(0)
dynamics.dispose()
dynamics.dispose()
expect(FakeRenderTarget.instances.every(target => target.dispose.mock.calls.length === 1)).toBe(true)
expect(material.dispose).toHaveBeenCalledOnce()
})
it('keeps the established field injection and decay equations', () => {
expect(GLASS_FLUID_FIELD_FRAGMENT_SHADER).toContain('previousEnergy * uDecay')
expect(GLASS_FLUID_FIELD_FRAGMENT_SHADER).toContain('distanceSquared * 437.500')
expect(GLASS_FLUID_FIELD_FRAGMENT_SHADER).toContain('distanceSquared * 262.500')
expect(GLASS_FLUID_FIELD_FRAGMENT_SHADER).toContain('injection * 0.44')
expect(GLASS_FLUID_FIELD_FRAGMENT_SHADER).not.toContain('uImpulse')
})
})

View File

@@ -0,0 +1,385 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
createGlassRippleDynamics,
RIPPLE_FRAGMENT_SHADER,
type GlassRippleQuality,
} from '@/rendering/glass/glassRippleDynamics'
class FakeVector2 {
constructor(
public x = 0,
public y = 0,
) {}
copy(value: FakeVector2) {
return this.set(value.x, value.y)
}
set(x: number, y: number) {
this.x = x
this.y = y
return this
}
}
class FakeRenderTarget {
static instances: FakeRenderTarget[] = []
readonly dispose = vi.fn()
height = 1
readonly setSize = vi.fn((width: number, height: number) => {
this.width = width
this.height = height
})
readonly texture: Record<string, unknown>
width = 1
constructor(
_width: number,
_height: number,
readonly options: Record<string, unknown>,
) {
this.texture = {
format: options.format,
magFilter: options.magFilter,
minFilter: options.minFilter,
type: options.type,
wrapS: options.wrapS,
wrapT: options.wrapT,
}
FakeRenderTarget.instances.push(this)
}
}
class FakeShaderMaterial {
static instances: FakeShaderMaterial[] = []
readonly dispose = vi.fn()
readonly fragmentShader: string
readonly uniforms: Record<string, { value: unknown }>
constructor(options: { fragmentShader: string; uniforms: Record<string, { value: unknown }> }) {
this.fragmentShader = options.fragmentShader
this.uniforms = options.uniforms
FakeShaderMaterial.instances.push(this)
}
}
class FakeScene {
readonly children: FakeMesh[] = []
add(mesh: FakeMesh) {
this.children.push(mesh)
}
}
class FakeMesh {
frustumCulled = true
constructor(
readonly geometry: unknown,
readonly material: FakeShaderMaterial,
) {}
}
interface RenderSnapshot {
direction: { x: number; y: number }
energyDecay: number
heightDecay: number
impulse: number
impulseCenter: { x: number; y: number }
impulseOffset: number
impulseSigma: number
impulseSpeed: number
reset: number
step: number
target: FakeRenderTarget | null
velocityDecay: number
}
function createRippleHarness(
quality: GlassRippleQuality = 'balanced',
compileAsync = vi.fn().mockResolvedValue(undefined),
supportsHalfFloatTarget = true,
) {
let currentTarget: FakeRenderTarget | null = null
const snapshots: RenderSnapshot[] = []
const renderer = {
compileAsync,
extensions: {
has: vi.fn(() => supportsHalfFloatTarget),
},
getRenderTarget: vi.fn(() => currentTarget),
render: vi.fn((scene: FakeScene) => {
const uniforms = scene.children[0].material.uniforms
snapshots.push({
direction: {
x: (uniforms.uImpulseDirection.value as FakeVector2).x,
y: (uniforms.uImpulseDirection.value as FakeVector2).y,
},
energyDecay: uniforms.uEnergyDecay.value as number,
heightDecay: uniforms.uHeightDecay.value as number,
impulse: uniforms.uImpulse.value as number,
impulseCenter: {
x: (uniforms.uImpulseCenter.value as FakeVector2).x,
y: (uniforms.uImpulseCenter.value as FakeVector2).y,
},
impulseOffset: uniforms.uImpulseOffset.value as number,
impulseSigma: uniforms.uImpulseSigma.value as number,
impulseSpeed: uniforms.uImpulseSpeed.value as number,
reset: uniforms.uReset.value as number,
step: uniforms.uStep.value as number,
target: currentTarget,
velocityDecay: uniforms.uVelocityDecay.value as number,
})
}),
setRenderTarget: vi.fn((target: FakeRenderTarget | null) => {
currentTarget = target
}),
setScissorTest: vi.fn(),
}
const three = {
ClampToEdgeWrapping: 1001,
HalfFloatType: 1005,
LinearFilter: 1002,
Mesh: FakeMesh,
RGBAFormat: 1003,
Scene: FakeScene,
ShaderMaterial: FakeShaderMaterial,
UnsignedByteType: 1004,
Vector2: FakeVector2,
WebGLRenderTarget: FakeRenderTarget,
} as unknown as typeof import('three')
return {
create: () =>
createGlassRippleDynamics({
camera: {} as never,
geometry: {} as never,
quality,
renderer: renderer as never,
three,
viewportHeight: 800,
viewportWidth: 1200,
}),
renderer,
snapshots,
}
}
beforeEach(() => {
FakeRenderTarget.instances = []
FakeShaderMaterial.instances = []
})
describe('glass ripple dynamics', () => {
it('uses one bounded half-float ping-pong field when the renderer supports it', async () => {
const harness = createRippleHarness()
const dynamics = await harness.create()
expect(FakeRenderTarget.instances).toHaveLength(2)
expect(FakeRenderTarget.instances.map(target => [target.width, target.height])).toEqual([
[192, 128],
[192, 128],
])
for (const target of FakeRenderTarget.instances) {
expect(target.options).toMatchObject({
depthBuffer: false,
format: 1003,
magFilter: 1002,
minFilter: 1002,
stencilBuffer: false,
type: 1005,
wrapS: 1001,
wrapT: 1001,
})
expect(target.texture.generateMipmaps).toBe(false)
}
expect(harness.renderer.compileAsync).toHaveBeenCalledOnce()
expect(harness.snapshots).toHaveLength(2)
expect(harness.snapshots.every(snapshot => snapshot.reset === 1)).toBe(true)
expect(new Set(harness.snapshots.map(snapshot => snapshot.target))).toEqual(new Set(FakeRenderTarget.instances))
expect(dynamics.texture).toBeNull()
expect(dynamics.texelSize.x).toBeCloseTo(1 / 192)
expect(dynamics.texelSize.y).toBeCloseTo(1 / 128)
dynamics.dispose()
expect(FakeRenderTarget.instances.every(target => target.dispose.mock.calls.length === 1)).toBe(true)
expect(FakeShaderMaterial.instances[0].dispose).toHaveBeenCalledOnce()
})
it('keeps the documented neutral encoding, stencil weights and bounded impulse kernel', () => {
expect(RIPPLE_FRAGMENT_SHADER).toContain('vec4(0.5, 0.5, 0.0, 1.0)')
expect(RIPPLE_FRAGMENT_SHADER).toContain('sampleValue.b < (1.0 / 255.0)')
expect(RIPPLE_FRAGMENT_SHADER).toContain('cardinal1 * 0.72 + cardinal2 * 0.28')
expect(RIPPLE_FRAGMENT_SHADER).toContain(
'cardinal1 * 0.46 + diagonal1 * 0.22 + cardinal2 * 0.20 + diagonal2 * 0.12',
)
expect(RIPPLE_FRAGMENT_SHADER).toContain('float directionalRadius = length(vec2(along * 0.72, across * 1.24))')
expect(RIPPLE_FRAGMENT_SHADER).toContain('float centerRelease = smoothstep(0.0, 0.55, normalizedRadius)')
expect(RIPPLE_FRAGMENT_SHADER).toContain('float annularCore = normalizedRadius * core')
expect(RIPPLE_FRAGMENT_SHADER).toContain(
'float radialImpulse = (0.72 * annularCore - 0.3 * ring) * centerRelease * uImpulse',
)
expect(RIPPLE_FRAGMENT_SHADER).not.toContain('0.58 * core')
expect(RIPPLE_FRAGMENT_SHADER).toContain('float directionalImpulse = clamp(')
expect(RIPPLE_FRAGMENT_SHADER).toContain('mix(radialImpulse, directionalImpulse')
expect(RIPPLE_FRAGMENT_SHADER).toContain('impulse * mix(0.52, 0.82, speedResponse)')
expect(RIPPLE_FRAGMENT_SHADER).not.toContain('uHeightDecay + impulse')
})
it('resizes in viewport space, clears the field and releases every owned resource', async () => {
const harness = createRippleHarness('high')
const dynamics = await harness.create()
harness.snapshots.length = 0
dynamics.inject({
direction: { x: 1, y: 0 },
point: { x: 0.4, y: 0.6 },
speed: 0.8,
timestamp: 100,
})
expect(dynamics.step(116.667)).toBe(true)
expect(dynamics.texture).not.toBeNull()
dynamics.resize(1600, 900)
expect(FakeRenderTarget.instances.map(target => [target.width, target.height])).toEqual([
[400, 225],
[400, 225],
])
expect(harness.snapshots.slice(-2).every(snapshot => snapshot.reset === 1)).toBe(true)
expect(dynamics.texture).toBeNull()
expect(dynamics.texelSize.x).toBeCloseTo(1 / 400)
expect(dynamics.texelSize.y).toBeCloseTo(1 / 225)
dynamics.dispose()
expect(FakeRenderTarget.instances.every(target => target.dispose.mock.calls.length === 1)).toBe(true)
expect(FakeShaderMaterial.instances[0].dispose).toHaveBeenCalledOnce()
})
it('falls back to an 8-bit field when half-float color targets are unavailable', async () => {
const harness = createRippleHarness('balanced', vi.fn().mockResolvedValue(undefined), false)
const dynamics = await harness.create()
expect(FakeRenderTarget.instances.every(target => target.options.type === 1004)).toBe(true)
dynamics.dispose()
})
it('clears flow-zero feedback on the next frame and then stops all GPU work', async () => {
const harness = createRippleHarness()
const dynamics = await harness.create()
dynamics.setParameters(50, 0)
dynamics.inject({
direction: { x: 0.8, y: 0.2 },
point: { x: 0.35, y: 0.65 },
speed: 0.7,
timestamp: 100,
})
harness.snapshots.length = 0
expect(dynamics.step(116.667)).toBe(true)
expect(harness.snapshots).toHaveLength(1)
expect(dynamics.texture).not.toBeNull()
expect(dynamics.step(133.334)).toBe(false)
expect(harness.snapshots).toHaveLength(3)
expect(harness.snapshots.slice(-2).every(snapshot => snapshot.reset === 1)).toBe(true)
expect(dynamics.texture).toBeNull()
expect(dynamics.step(150)).toBe(false)
expect(harness.snapshots).toHaveLength(3)
dynamics.dispose()
})
it('caps propagation at two substeps while applying decay over the full elapsed time', async () => {
const harness = createRippleHarness()
const dynamics = await harness.create()
dynamics.setParameters(50, 50)
dynamics.inject({
direction: { x: 1, y: 0 },
point: { x: 0.5, y: 0.5 },
speed: 0.5,
timestamp: 100,
})
dynamics.step(116.667)
harness.snapshots.length = 0
expect(dynamics.step(166.667)).toBe(true)
expect(harness.snapshots).toHaveLength(2)
const velocityHalfLife = 145
const expectedSubstepDecay = 2 ** (-25 / velocityHalfLife)
expect(harness.snapshots.every(snapshot => snapshot.step === 1)).toBe(true)
expect(
harness.snapshots.every(snapshot => Math.abs(snapshot.velocityDecay - expectedSubstepDecay) < 0.000001),
).toBe(true)
dynamics.dispose()
})
it('integrates directions while retaining the latest point and maximum impulse within one frame', async () => {
const harness = createRippleHarness()
const dynamics = await harness.create()
harness.snapshots.length = 0
dynamics.inject({
direction: { x: 1, y: 0 },
point: { x: 0.25, y: 0.35 },
speed: 1,
timestamp: 100,
})
dynamics.inject({
direction: { x: 0, y: 1 },
point: { x: 0.7, y: 0.8 },
speed: 0.2,
timestamp: 104,
})
expect(dynamics.step(116.667)).toBe(true)
expect(harness.snapshots).toHaveLength(1)
expect(harness.snapshots[0].direction.x).toBeCloseTo(Math.SQRT1_2)
expect(harness.snapshots[0].direction.y).toBeCloseTo(Math.SQRT1_2)
expect(harness.snapshots[0].impulseCenter).toEqual({ x: 0.7, y: 0.8 })
expect(harness.snapshots[0].impulse).toBeCloseTo(0.8)
expect(harness.snapshots[0].impulseOffset).toBe(28)
expect(harness.snapshots[0].impulseSigma).toBeCloseTo(75.6)
expect(harness.snapshots[0].impulseSpeed).toBe(1)
dynamics.dispose()
})
it('keeps the ripple footprint stable across quality levels', async () => {
const balancedHarness = createRippleHarness('balanced')
const balancedDynamics = await balancedHarness.create()
const highHarness = createRippleHarness('high')
const highDynamics = await highHarness.create()
const interaction = {
direction: { x: 1, y: 0 },
point: { x: 0.5, y: 0.5 },
speed: 0.5,
timestamp: 100,
}
balancedHarness.snapshots.length = 0
highHarness.snapshots.length = 0
balancedDynamics.setParameters(75, 50)
highDynamics.setParameters(75, 50)
balancedDynamics.inject(interaction)
highDynamics.inject(interaction)
balancedDynamics.step(116.667)
highDynamics.step(116.667)
expect(balancedHarness.snapshots[0].impulseSigma).toBeCloseTo(86.4)
expect(highHarness.snapshots[0].impulseSigma).toBeCloseTo(86.4)
balancedDynamics.dispose()
highDynamics.dispose()
})
it('disposes partially created resources when shader compilation fails', async () => {
const compileAsync = vi.fn().mockRejectedValue(new Error('compile failed'))
const harness = createRippleHarness('balanced', compileAsync)
await expect(harness.create()).rejects.toThrow('compile failed')
expect(FakeRenderTarget.instances.every(target => target.dispose.mock.calls.length === 1)).toBe(true)
expect(FakeShaderMaterial.instances[0].dispose).toHaveBeenCalledOnce()
})
})

View File

@@ -0,0 +1,335 @@
import type {
BufferGeometry,
IUniform,
OrthographicCamera,
Texture,
Vector2,
WebGLRenderer,
WebGLRenderTarget,
} from 'three'
type ThreeModule = typeof import('three')
interface GlassFluidFieldUniforms extends Record<string, IUniform> {
uDecay: IUniform<number>
uInjection: IUniform<number>
uPointer: IUniform<Vector2>
uPrevious: IUniform<Texture | null>
uTexelSize: IUniform<Vector2>
uVelocity: IUniform<Vector2>
uViewportAspect: IUniform<number>
}
interface CreateGlassFluidDynamicsOptions {
camera: OrthographicCamera
geometry: BufferGeometry
pointer: Vector2
renderer: WebGLRenderer
three: ThreeModule
velocity: Vector2
}
export interface GlassFluidDynamics {
/** 清除当前输入包络;下一帧会把时序场收敛到中性值。 */
clearInput(): void
/** 释放 fluid 私有 shader 和两个 ping-pong target。 */
dispose(): void
/** 清除当前帧注入,避免非输入绘制重复写入同一能量。 */
finishFrame(): void
/** 调整 fluid field尺寸只来自主 renderer 已提交的 buffer。 */
resize(bufferWidth: number, bufferHeight: number, viewportWidth: number, viewportHeight: number): void
/** 更新当前帧的衰减与注入参数,不自行调度动画。 */
setFrameParameters(decay: number, injection: number): void
/** 推进一次 field 并返回主材质应采样的最新纹理。 */
step(): Texture
}
export const GLASS_FLUID_DYNAMIC_RANGE_SCALE = 0.4
export const GLASS_FLUID_DYNAMIC_RANGE_DENSITY = 1 / GLASS_FLUID_DYNAMIC_RANGE_SCALE ** 2
const GLASS_FLUID_BUFFER_SCALE = 0.25
const GLASS_FLUID_VERTEX_SHADER = `
varying vec2 vUv;
void main() {
vUv = position.xy * 0.5 + 0.5;
gl_Position = vec4(position.xy, 0.0, 1.0);
}
`
export const GLASS_FLUID_FIELD_FRAGMENT_SHADER = `
precision highp float;
uniform sampler2D uPrevious;
uniform vec2 uPointer;
uniform vec2 uVelocity;
uniform vec2 uTexelSize;
uniform float uInjection;
uniform float uDecay;
uniform float uViewportAspect;
varying vec2 vUv;
void main() {
vec4 previous = (
texture2D(uPrevious, vUv) * 0.5 +
texture2D(uPrevious, vUv + vec2(uTexelSize.x, 0.0)) * 0.125 +
texture2D(uPrevious, vUv - vec2(uTexelSize.x, 0.0)) * 0.125 +
texture2D(uPrevious, vUv + vec2(0.0, uTexelSize.y)) * 0.125 +
texture2D(uPrevious, vUv - vec2(0.0, uTexelSize.y)) * 0.125
);
float previousEnergy = previous.z;
vec2 flow = previousEnergy < 0.001 ? vec2(0.0) : (previous.xy * 2.0 - 1.0) * uDecay;
float energy = previousEnergy * uDecay;
vec2 delta = vUv - uPointer;
delta.x *= uViewportAspect;
float distanceSquared = dot(delta, delta);
float injection = exp(-distanceSquared * ${(70 * GLASS_FLUID_DYNAMIC_RANGE_DENSITY).toFixed(3)}) * uInjection;
float speed = length(uVelocity);
vec2 direction = speed > 0.0001 ? uVelocity / speed : vec2(0.0, -1.0);
vec2 perpendicular = vec2(-direction.y, direction.x);
float shear = dot(delta, perpendicular) * exp(-distanceSquared * ${(42 * GLASS_FLUID_DYNAMIC_RANGE_DENSITY).toFixed(3)});
flow += (direction * min(speed * 9.0, 0.9) - perpendicular * shear * 0.85) * injection * 0.44;
energy = max(energy, injection);
gl_FragColor = vec4(flow * 0.5 + 0.5, energy, 1.0);
}
`
/** fluid 主材质的全局临时量;由共享 shader 在原位置逐字拼装。 */
export const GLASS_FLUID_FRAGMENT_SETUP = ` vec2 wakeDirection = length(uWakeDirection) > 0.0001 ? normalize(uWakeDirection) : vec2(0.0, -1.0);
vec2 wakePerpendicular = vec2(-wakeDirection.y, wakeDirection.x);
vec2 trailRefraction = vec2(0.0);
float trailEnergy = 0.0;
float trailSpatialSpan = 0.0;
float motionRangeCompression = mix(1.0, 1.34, uMotionExpansion);
const float dynamicRangeScale = ${GLASS_FLUID_DYNAMIC_RANGE_SCALE.toFixed(2)};
const float dynamicRangeDensity = ${GLASS_FLUID_DYNAMIC_RANGE_DENSITY.toFixed(3)};`
/** fluid 的 trail 与高质量 temporal field 响应。 */
export const GLASS_FLUID_FRAGMENT_TRAIL_AND_FIELD = ` for (int trailIndex = 0; trailIndex < 4; trailIndex++) {
if (trailIndex >= uTrailCount) break;
vec4 trail = uTrail[trailIndex];
vec2 trailDelta = vUv - trail.xy;
trailDelta *= uPresentationSize / max(uVisibleViewportSize.y, 1.0) * motionRangeCompression;
vec2 trailSpanDelta = trail.xy - uPointer;
trailSpanDelta *= uPresentationSize / max(uVisibleViewportSize.y, 1.0) * motionRangeCompression;
trailSpatialSpan = max(trailSpatialSpan, length(trailSpanDelta) * trail.z);
float along = dot(trailDelta, wakeDirection);
float across = dot(trailDelta, wakePerpendicular);
float trailAlongDensity = mix(42.0, 22.0, uMotionExpansion) * dynamicRangeDensity;
float trailAcrossDensity = mix(210.0, 86.0, uMotionExpansion) * dynamicRangeDensity;
float lobe =
exp(-(along * along * trailAlongDensity + across * across * trailAcrossDensity)) * trail.z * uMotion;
float wake = mix(0.88, 0.58, float(trailIndex) / 3.0);
trailRefraction +=
(wakeDirection * 0.0048 + wakePerpendicular * across * 0.018) *
lobe *
uDeformationStrength *
uFlowStrength;
trailEnergy += lobe * wake * mix(0.72, 0.42, float(trailIndex) / 3.0);
}
vec4 flowSample = uHasFlowTexture > 0.5 ? texture2D(uFlowTexture, vUv) : vec4(0.5, 0.5, 0.0, 1.0);
vec2 temporalFlow =
uHasFlowTexture > 0.5
? (flowSample.xy * 2.0 - 1.0) *
flowSample.z *
uMotion *
uDeformationStrength *
uFlowStrength
: vec2(0.0);
float flowSurfaceDetail = 0.0;
if (uQuality > 0.5 && uHasFlowTexture > 0.5) {
vec2 flowTexel = vec2(3.0) / max(uPresentationSize, vec2(1.0));
vec3 flowLeft = texture2D(uFlowTexture, vUv - vec2(flowTexel.x, 0.0)).xyz;
vec3 flowRight = texture2D(uFlowTexture, vUv + vec2(flowTexel.x, 0.0)).xyz;
vec3 flowBottom = texture2D(uFlowTexture, vUv - vec2(0.0, flowTexel.y)).xyz;
vec3 flowTop = texture2D(uFlowTexture, vUv + vec2(0.0, flowTexel.y)).xyz;
float flowGradient = length(flowRight.xy - flowLeft.xy) + length(flowTop.xy - flowBottom.xy);
float energyGradient = abs(flowRight.z - flowLeft.z) + abs(flowTop.z - flowBottom.z);
flowSurfaceDetail = smoothstep(0.015, 0.24, flowGradient + energyGradient * 0.72) * uMotion;
}`
/** 单个 surface 内的 fluid 指针、方向、wake 与能量形态。 */
export const GLASS_FLUID_FRAGMENT_SURFACE_SHAPE = ` vec2 pointerDelta = uPointer - vUv;
vec2 pointerDeltaAspect = pointerDelta;
pointerDeltaAspect *= uPresentationSize / max(uVisibleViewportSize.y, 1.0) * motionRangeCompression;
// 三材质共享指针几何足迹;磨砂身份由位移幅度、低通扩散和材质合成表达。
float pointerSpread = mix(26.0, 17.0, uQuality);
pointerSpread *= dynamicRangeDensity * mix(1.0, 0.46, uMotionExpansion);
float sharedDirectionality = smoothstep(0.015, 0.18, trailSpatialSpan);
float pointerAlong = dot(-pointerDeltaAspect, wakeDirection);
float pointerAcross = dot(-pointerDeltaAspect, wakePerpendicular);
float sharedWakeTravel =
0.08 * sharedDirectionality * mix(0.86, 1.18, uMotionExpansion);
float radialPointerShape = exp(-dot(pointerDeltaAspect, pointerDeltaAspect) * pointerSpread);
float directionalPointerShape =
exp(-(
pow(pointerAlong + sharedWakeTravel * 0.45, 2.0) * pointerSpread * 0.72 +
pointerAcross * pointerAcross * pointerSpread * 1.35
));
float pointerEnergy =
clamp(mix(radialPointerShape, directionalPointerShape, sharedDirectionality) * uMotion, 0.0, 1.0);
float sharedWaveDensity = mix(2.81, 1.63, uMotionExpansion);
float radialSharedWave =
exp(-dot(pointerDeltaAspect, pointerDeltaAspect) * sharedWaveDensity);
float directionalSharedWave =
exp(-(
pow(pointerAlong + sharedWakeTravel, 2.0) * sharedWaveDensity * 0.62 +
pointerAcross * pointerAcross * sharedWaveDensity * 2.2
));
float sharedWaveEnergy =
mix(radialSharedWave, directionalSharedWave, sharedDirectionality) *
clamp(length(uPointerVelocity) * 14.0 * uTranslationStrength, 0.0, 1.0) *
mix(1.0, 0.78, sharedDirectionality) *
uMotion *
uMotion;
vec2 wakeDelta = vUv - uPointer;
wakeDelta *= uPresentationSize / max(uVisibleViewportSize.y, 1.0) * motionRangeCompression;
float wakeAlong = dot(wakeDelta, wakeDirection);
float wakeAcross = dot(wakeDelta, wakePerpendicular);
float wakeTravel =
0.014 * dynamicRangeScale *
mix(0.82, 1.18, uQuality) *
mix(1.0, 1.45, uMotionExpansion);
float wakeWidth =
mix(0.027, 0.044, uQuality) * dynamicRangeScale * mix(1.0, 1.72, uMotionExpansion);
float wakeCoordinate = (wakeAlong + wakeTravel) / wakeWidth;
float wakeShape = wakeCoordinate * exp(-0.5 * wakeCoordinate * wakeCoordinate);
float wakeEnvelope =
exp(
-wakeAcross *
wakeAcross *
mix(280.0, 145.0, uQuality) *
dynamicRangeDensity *
mix(1.0, 0.44, uMotionExpansion)
);
vec2 wakeRefraction =
wakeDirection *
wakeShape *
wakeEnvelope *
mix(0.0045, 0.0075, uQuality) *
uMotion *
uDeformationStrength *
uFlowStrength;
float wakeEnergy = abs(wakeShape) * wakeEnvelope * uMotion;
float liquidEnergy = clamp(max(
pointerEnergy,
max(min(1.0, trailEnergy) * 0.68, wakeEnergy * 0.82)
), 0.0, 1.0);`
/** 单个 surface 内的 fluid 高光与焦散响应。 */
export const GLASS_FLUID_FRAGMENT_SURFACE_OPTICS = ` float pointerStrength = mix(mix(0.0055, 0.008, uQuality), mix(0.0085, 0.012, uQuality), frosted);
float trailStrength = mix(mix(0.78, 1.08, uQuality), mix(0.96, 1.3, uQuality), frosted);
float temporalStrength = mix(0.032, 0.042, frosted) * uQuality * (1.0 + flowSurfaceDetail * 0.5);
vec2 specularDelta =
vUv - (uPointer - wakeDirection * mix(0.006, 0.022, uMotionExpansion) * dynamicRangeScale);
specularDelta *= uPresentationSize / max(uVisibleViewportSize.y, 1.0) * motionRangeCompression;
float specularAlong = dot(specularDelta, wakeDirection);
float specularAcross = dot(specularDelta, wakePerpendicular);
float singleSpecular =
exp(-(
specularAlong * specularAlong * mix(58.0, 25.0, uMotionExpansion) * dynamicRangeDensity +
specularAcross * specularAcross * mix(190.0, 78.0, uMotionExpansion) * dynamicRangeDensity
)) *
uMotion *
mix(1.0, 1.24, uMotionExpansion);
float localCaustic = singleSpecular * rectMask * surfaceDynamic * interactionMask;`
/** fluid 对共享 dynamicRefraction 的贡献;静态透镜和 ripple 响应仍由主材质合成。 */
export const GLASS_FLUID_FRAGMENT_SURFACE_REFRACTION = ` vec2 sampleTranslation =
uPointerVelocity *
mix(0.055, 0.075, uQuality) *
uMotion *
uTranslationStrength;
dynamicRefraction += (
sampleTranslation +
// 收紧高斯半径时补偿向量峰值,避免范围缩小同时削弱用户设置的形变强度。
pointerDelta * pointerEnergy * pointerStrength * uDeformationStrength / dynamicRangeScale +
trailRefraction * trailStrength +
temporalFlow * temporalStrength +
wakeRefraction
) * rectMask * surfaceDynamic * interactionMask;`
/** 创建仅由高质量 fluid 模式持有的时序位移场。 */
export function createGlassFluidDynamics(options: CreateGlassFluidDynamicsOptions): GlassFluidDynamics {
const { camera, geometry, pointer, renderer, three, velocity } = options
let disposed = false
const createTarget = () =>
new three.WebGLRenderTarget(1, 1, {
depthBuffer: false,
magFilter: three.LinearFilter,
minFilter: three.LinearFilter,
stencilBuffer: false,
})
let readTarget: WebGLRenderTarget = createTarget()
let writeTarget: WebGLRenderTarget = createTarget()
const uniforms: GlassFluidFieldUniforms = {
uDecay: { value: 1 },
uInjection: { value: 0 },
uPointer: { value: pointer },
uPrevious: { value: null },
uTexelSize: { value: new three.Vector2(1, 1) },
uVelocity: { value: velocity },
uViewportAspect: { value: window.innerWidth / Math.max(window.innerHeight, 1) },
}
const material = new three.ShaderMaterial({
depthTest: false,
depthWrite: false,
fragmentShader: GLASS_FLUID_FIELD_FRAGMENT_SHADER,
uniforms,
vertexShader: GLASS_FLUID_VERTEX_SHADER,
})
const scene = new three.Scene()
const mesh = new three.Mesh(geometry, material)
mesh.frustumCulled = false
scene.add(mesh)
return {
clearInput() {
uniforms.uDecay.value = 0
uniforms.uInjection.value = 0
},
dispose() {
if (disposed) return
disposed = true
material.dispose()
readTarget.dispose()
writeTarget.dispose()
},
finishFrame() {
uniforms.uInjection.value = 0
},
resize(bufferWidth, bufferHeight, viewportWidth, viewportHeight) {
if (disposed) return
const width = Math.max(96, Math.round(bufferWidth * GLASS_FLUID_BUFFER_SCALE))
const height = Math.max(96, Math.round(bufferHeight * GLASS_FLUID_BUFFER_SCALE))
if (readTarget.width !== width || readTarget.height !== height) {
readTarget.setSize(width, height)
writeTarget.setSize(width, height)
}
uniforms.uTexelSize.value.set(1 / width, 1 / height)
uniforms.uViewportAspect.value = viewportWidth / Math.max(viewportHeight, 1)
},
setFrameParameters(decay, injection) {
uniforms.uDecay.value = decay
uniforms.uInjection.value = injection
},
step() {
if (disposed) return readTarget.texture
renderer.setScissorTest(false)
uniforms.uPrevious.value = readTarget.texture
renderer.setRenderTarget(writeTarget)
renderer.render(scene, camera)
renderer.setRenderTarget(null)
const previousReadTarget = readTarget
readTarget = writeTarget
writeTarget = previousReadTarget
return readTarget.texture
},
}
}

View File

@@ -0,0 +1,480 @@
import type {
BufferGeometry,
IUniform,
OrthographicCamera,
Texture,
Vector2,
WebGLRenderer,
WebGLRenderTarget,
} from 'three'
import type { GlassOpticalQuality } from '@/utils/glassOptics'
type ThreeModule = typeof import('three')
export type GlassRippleQuality = Exclude<GlassOpticalQuality, 'css'>
interface GlassRippleUniforms extends Record<string, IUniform> {
uEnergyDecay: IUniform<number>
uHeightDecay: IUniform<number>
uImpulse: IUniform<number>
uImpulseCenter: IUniform<Vector2>
uImpulseDirection: IUniform<Vector2>
uImpulseOffset: IUniform<number>
uImpulseSigma: IUniform<number>
uImpulseSpeed: IUniform<number>
uPrevious: IUniform<Texture | null>
uPropagation: IUniform<number>
uQuality: IUniform<number>
uReset: IUniform<number>
uRestoring: IUniform<number>
uStep: IUniform<number>
uTexelSize: IUniform<Vector2>
uVelocityDecay: IUniform<number>
uViewportSize: IUniform<Vector2>
}
export interface GlassRippleInteraction {
/** CSS viewport 中归一化后的输入位置Y 轴以 WebGL 底部为原点。 */
point: { x: number; y: number }
/** 归一化后的指针移动方向。 */
direction: { x: number; y: number }
/** 现有 renderer 归一化后的速度强度,范围 0 到 1。 */
speed: number
/** 与 performance timeline 一致的事件时间。 */
timestamp: number
}
export interface GlassRippleDynamics {
/** 释放波场及其 GPU 资源。 */
dispose(): void
/** 将输入合并到下一次 GPU step不为每个事件立即绘制。 */
inject(interaction: GlassRippleInteraction): void
/** 当前可供主材质采样的波场纹理;空场返回 null。 */
readonly texture: Texture | null
/** 当前波场单 texel 的 UV 尺寸,供主材质计算高度梯度。 */
readonly texelSize: Vector2
/** 更新共享动态参数,不重建 GPU 资源。 */
setParameters(translationStrength: number, flowStrength: number): void
/** 调整 viewport-space 波场;尺寸变化会恢复为空场。 */
resize(viewportWidth: number, viewportHeight: number): void
/** 立即清空两个 ping-pong target并停止 CPU 生命周期。 */
reset(): void
/** 推进一步波场;返回 false 表示已提交清场并停止。 */
step(timestamp: number): boolean
}
interface CreateGlassRippleDynamicsOptions {
camera: OrthographicCamera
geometry: BufferGeometry
quality: GlassRippleQuality
renderer: WebGLRenderer
three: ThreeModule
viewportHeight: number
viewportWidth: number
}
const RIPPLE_VERTEX_SHADER = `
varying vec2 vUv;
void main() {
vUv = position.xy * 0.5 + 0.5;
gl_Position = vec4(position.xy, 0.0, 1.0);
}
`
export const RIPPLE_FRAGMENT_SHADER = `
precision highp float;
uniform sampler2D uPrevious;
uniform vec2 uTexelSize;
uniform vec2 uViewportSize;
uniform vec2 uImpulseCenter;
uniform vec2 uImpulseDirection;
uniform float uImpulse;
uniform float uImpulseOffset;
uniform float uImpulseSigma;
uniform float uImpulseSpeed;
uniform float uPropagation;
uniform float uRestoring;
uniform float uVelocityDecay;
uniform float uHeightDecay;
uniform float uEnergyDecay;
uniform float uStep;
uniform float uQuality;
uniform float uReset;
varying vec2 vUv;
vec3 decodeState(vec4 sampleValue) {
if (sampleValue.b < (1.0 / 255.0)) return vec3(0.0);
return vec3(sampleValue.rg * 2.0 - 1.0, sampleValue.b);
}
float sampleHeight(vec2 offset) {
return decodeState(texture2D(uPrevious, clamp(vUv + offset, vec2(0.0), vec2(1.0)))).x;
}
void main() {
if (uReset > 0.5) {
gl_FragColor = vec4(0.5, 0.5, 0.0, 1.0);
return;
}
vec3 previous = decodeState(texture2D(uPrevious, vUv));
float h = previous.x;
float velocity = previous.y;
float energy = previous.z;
float cardinal1 = (
sampleHeight(vec2(uTexelSize.x, 0.0)) +
sampleHeight(vec2(-uTexelSize.x, 0.0)) +
sampleHeight(vec2(0.0, uTexelSize.y)) +
sampleHeight(vec2(0.0, -uTexelSize.y))
) * 0.25;
float cardinal2 = (
sampleHeight(vec2(uTexelSize.x * 2.0, 0.0)) +
sampleHeight(vec2(-uTexelSize.x * 2.0, 0.0)) +
sampleHeight(vec2(0.0, uTexelSize.y * 2.0)) +
sampleHeight(vec2(0.0, -uTexelSize.y * 2.0))
) * 0.25;
float diagonal1 = (
sampleHeight(vec2(uTexelSize.x, uTexelSize.y)) +
sampleHeight(vec2(-uTexelSize.x, uTexelSize.y)) +
sampleHeight(vec2(uTexelSize.x, -uTexelSize.y)) +
sampleHeight(vec2(-uTexelSize.x, -uTexelSize.y))
) * 0.25;
float diagonal2 = (
sampleHeight(vec2(uTexelSize.x * 2.0, uTexelSize.y * 2.0)) +
sampleHeight(vec2(-uTexelSize.x * 2.0, uTexelSize.y * 2.0)) +
sampleHeight(vec2(uTexelSize.x * 2.0, -uTexelSize.y * 2.0)) +
sampleHeight(vec2(-uTexelSize.x * 2.0, -uTexelSize.y * 2.0))
) * 0.25;
float balancedMean = cardinal1 * 0.72 + cardinal2 * 0.28;
float highMean = cardinal1 * 0.46 + diagonal1 * 0.22 + cardinal2 * 0.20 + diagonal2 * 0.12;
float curvature = mix(balancedMean, highMean, uQuality) - h;
float speedResponse = smoothstep(0.0, 1.0, uImpulseSpeed);
vec2 shiftedCenter = uImpulseCenter +
uImpulseDirection * uImpulseOffset * mix(0.35, 1.0, speedResponse) / max(uViewportSize, vec2(1.0));
vec2 impulseDelta = (vUv - shiftedCenter) * uViewportSize;
float directionLength = length(uImpulseDirection);
vec2 flowDirection = directionLength > 0.0001 ? uImpulseDirection / directionLength : vec2(0.0, 1.0);
vec2 flowPerpendicular = vec2(-flowDirection.y, flowDirection.x);
float along = dot(impulseDelta, flowDirection);
float across = dot(impulseDelta, flowPerpendicular);
float directionalRadius = length(vec2(along * 0.72, across * 1.24));
float directionality = step(0.0001, directionLength) * mix(0.32, 0.72, speedResponse);
float radius = mix(length(impulseDelta), directionalRadius, directionality);
float sigma = max(uImpulseSigma * mix(0.86, 1.05, speedResponse), 1.0);
float normalizedRadius = radius / sigma;
float core = exp(-0.5 * pow(normalizedRadius, 2.0));
float ring = exp(-0.5 * pow((radius - 1.6 * sigma) / (0.55 * sigma), 2.0));
float centerRelease = smoothstep(0.0, 0.55, normalizedRadius);
float annularCore = normalizedRadius * core;
float radialImpulse = (0.72 * annularCore - 0.3 * ring) * centerRelease * uImpulse;
float wakeEnvelope = exp(-0.5 * (
pow(along / (1.25 * sigma), 2.0) +
pow(across / (0.72 * sigma), 2.0)
));
float directionalImpulse = clamp((-along / sigma) * wakeEnvelope * uImpulse * 0.9, -0.62, 0.62);
float impulse = clamp(
mix(radialImpulse, directionalImpulse, directionality),
-0.62,
0.62
);
velocity = clamp(
(
velocity +
curvature * uPropagation * uStep -
h * uRestoring * uStep +
impulse * mix(0.52, 0.82, speedResponse)
) * uVelocityDecay,
-1.0,
1.0
);
h = clamp((h + velocity * uStep) * uHeightDecay, -1.0, 1.0);
energy = clamp(max(max(energy * uEnergyDecay, abs(h)), abs(impulse)), 0.0, 1.0);
gl_FragColor = vec4(h * 0.5 + 0.5, velocity * 0.5 + 0.5, energy, 1.0);
}
`
const FRESHNESS_MS = 40
const ENVELOPE_THRESHOLD = 0.006
const MAX_STEP_MS = 16.667
const MIN_STEP_MS = 4
function clamp01(value: number) {
return Math.min(1, Math.max(0, value))
}
function mix(start: number, end: number, progress: number) {
return start + (end - start) * progress
}
/** 创建仅由单个 renderer context 持有的 viewport-space 水漾场。 */
export async function createGlassRippleDynamics(
options: CreateGlassRippleDynamicsOptions,
): Promise<GlassRippleDynamics> {
const { camera, geometry, renderer, three } = options
const quality = options.quality
let viewportWidth = Math.max(1, options.viewportWidth)
let viewportHeight = Math.max(1, options.viewportHeight)
let translation = 0.5
let flow = 0.5
let energyAtInput = 0
let lastInputAt = Number.NEGATIVE_INFINITY
let deadlineAt = Number.NEGATIVE_INFINITY
let lastStepAt = 0
let pendingImpulse = 0
let pendingSpeed = 0
let impulseCenter = { x: 0.5, y: 0.5 }
let impulseDirection = { x: 0, y: 1 }
let pendingDirection = { x: 0, y: 0 }
let clearOnNextFrame = false
let fieldActive = false
let disposed = false
const targetType = renderer.extensions?.has?.('EXT_color_buffer_float') ? three.HalfFloatType : three.UnsignedByteType
const createTarget = () => {
const target = new three.WebGLRenderTarget(1, 1, {
depthBuffer: false,
format: three.RGBAFormat,
magFilter: three.LinearFilter,
minFilter: three.LinearFilter,
stencilBuffer: false,
type: targetType,
wrapS: three.ClampToEdgeWrapping,
wrapT: three.ClampToEdgeWrapping,
})
target.texture.generateMipmaps = false
return target
}
let readTarget = createTarget()
let writeTarget = createTarget()
const uniforms: GlassRippleUniforms = {
uEnergyDecay: { value: 1 },
uHeightDecay: { value: 1 },
uImpulse: { value: 0 },
uImpulseCenter: { value: new three.Vector2(0.5, 0.5) },
uImpulseDirection: { value: new three.Vector2(0, 1) },
uImpulseOffset: { value: 0 },
uImpulseSigma: { value: 24 },
uImpulseSpeed: { value: 0 },
uPrevious: { value: null },
uPropagation: { value: 0.18 },
uQuality: { value: quality === 'high' ? 1 : 0 },
uReset: { value: 1 },
uRestoring: { value: quality === 'high' ? 0.028 : 0.035 },
uStep: { value: 1 },
uTexelSize: { value: new three.Vector2(1, 1) },
uVelocityDecay: { value: 1 },
uViewportSize: { value: new three.Vector2(viewportWidth, viewportHeight) },
}
const material = new three.ShaderMaterial({
depthTest: false,
depthWrite: false,
fragmentShader: RIPPLE_FRAGMENT_SHADER,
uniforms,
vertexShader: RIPPLE_VERTEX_SHADER,
})
const scene = new three.Scene()
const mesh = new three.Mesh(geometry, material)
mesh.frustumCulled = false
scene.add(mesh)
const renderTarget = (target: WebGLRenderTarget) => {
const previousTarget = renderer.getRenderTarget()
try {
renderer.setScissorTest(false)
renderer.setRenderTarget(target)
renderer.render(scene, camera)
} finally {
renderer.setRenderTarget(previousTarget)
}
}
const writeNeutralTargets = () => {
uniforms.uReset.value = 1
uniforms.uPrevious.value = null
renderTarget(readTarget)
renderTarget(writeTarget)
uniforms.uReset.value = 0
}
const getTargetSize = (width: number, height: number) => {
const scale =
quality === 'high'
? Math.min(1, Math.max(0.25, 192 / width, 128 / height))
: Math.min(1, Math.max(0.16, 128 / width, 96 / height))
return {
height: Math.max(1, Math.round(height * scale)),
width: Math.max(1, Math.round(width * scale)),
}
}
const resize = (width: number, height: number) => {
if (disposed) return false
const nextViewportWidth = Math.max(1, width)
const nextViewportHeight = Math.max(1, height)
const target = getTargetSize(nextViewportWidth, nextViewportHeight)
const viewportChanged = viewportWidth !== nextViewportWidth || viewportHeight !== nextViewportHeight
const targetChanged = readTarget.width !== target.width || readTarget.height !== target.height
if (!viewportChanged && !targetChanged) return false
viewportWidth = nextViewportWidth
viewportHeight = nextViewportHeight
if (targetChanged) {
readTarget.setSize(target.width, target.height)
writeTarget.setSize(target.width, target.height)
}
uniforms.uTexelSize.value.set(1 / target.width, 1 / target.height)
uniforms.uViewportSize.value.set(viewportWidth, viewportHeight)
reset()
return true
}
const getVelocityHalfLife = () => (quality === 'high' ? mix(90, 280, flow) : mix(70, 220, flow))
const getDeadlineDuration = () => FRESHNESS_MS + (quality === 'high' ? mix(220, 920, flow) : mix(160, 680, flow))
const settleEnvelope = (timestamp: number) => {
if (!Number.isFinite(lastInputAt)) return 0
const freshReleaseAge = Math.max(0, timestamp - lastInputAt - FRESHNESS_MS)
const deadlineTaper = clamp01((deadlineAt - timestamp) / FRESHNESS_MS)
return energyAtInput * 2 ** (-freshReleaseAge / getVelocityHalfLife()) * deadlineTaper
}
const reset = () => {
if (disposed) return
writeNeutralTargets()
energyAtInput = 0
lastInputAt = Number.NEGATIVE_INFINITY
deadlineAt = Number.NEGATIVE_INFINITY
lastStepAt = 0
pendingImpulse = 0
pendingSpeed = 0
pendingDirection = { x: 0, y: 0 }
impulseDirection = { x: 0, y: 1 }
clearOnNextFrame = false
fieldActive = false
}
try {
const initializedByResize = resize(viewportWidth, viewportHeight)
await renderer.compileAsync(scene, camera)
if (disposed) throw new Error('Ripple resources were disposed during compilation')
if (!initializedByResize) reset()
} catch (error) {
material.dispose()
readTarget.dispose()
writeTarget.dispose()
throw error
}
return {
dispose() {
if (disposed) return
disposed = true
material.dispose()
readTarget.dispose()
writeTarget.dispose()
},
inject(interaction) {
if (disposed) return
const timestamp = interaction.timestamp
const previousEnvelope = settleEnvelope(timestamp)
const inputAmplitude = Math.min(0.8, Math.max(0.22, 0.22 + clamp01(interaction.speed) * 0.58))
energyAtInput = Math.max(previousEnvelope, inputAmplitude)
lastInputAt = timestamp
deadlineAt = timestamp + getDeadlineDuration()
pendingImpulse = Math.max(pendingImpulse, inputAmplitude)
pendingSpeed = Math.max(pendingSpeed, clamp01(interaction.speed))
impulseCenter = { x: clamp01(interaction.point.x), y: clamp01(interaction.point.y) }
const directionLength = Math.hypot(interaction.direction.x, interaction.direction.y)
if (directionLength > 0.0001) {
pendingDirection.x += interaction.direction.x / directionLength
pendingDirection.y += interaction.direction.y / directionLength
}
fieldActive = true
clearOnNextFrame = false
},
get texture() {
return fieldActive && !disposed ? readTarget.texture : null
},
get texelSize() {
return uniforms.uTexelSize.value
},
setParameters(translationStrength, flowStrength) {
translation = clamp01(translationStrength / 100)
flow = clamp01(flowStrength / 100)
},
resize,
reset,
step(timestamp) {
if (disposed || !fieldActive) return false
if (clearOnNextFrame || timestamp >= deadlineAt || settleEnvelope(timestamp) < ENVELOPE_THRESHOLD) {
reset()
return false
}
const elapsed = lastStepAt > 0 ? Math.max(0, timestamp - lastStepAt) : MAX_STEP_MS
const simulatedElapsed = Math.min(MAX_STEP_MS * 2, Math.max(MIN_STEP_MS, elapsed))
const substeps = simulatedElapsed > MAX_STEP_MS ? 2 : 1
const stepMs = Math.min(MAX_STEP_MS, Math.max(MIN_STEP_MS, simulatedElapsed / substeps))
const decayStepMs = elapsed / substeps
const velocityHalfLife = getVelocityHalfLife()
const heightHalfLife = velocityHalfLife * 0.82
const energyHalfLife = velocityHalfLife * 0.72
const targetCssPerTexel = Math.sqrt(
(viewportWidth / Math.max(readTarget.width, 1)) * (viewportHeight / Math.max(readTarget.height, 1)),
)
const referenceCssPerTexel = quality === 'high' ? 4 : 6.25
const basePropagation = quality === 'high' ? mix(0.11, 0.16, translation) : mix(0.12, 0.18, translation)
if (pendingImpulse > 0) {
const directionLength = Math.hypot(pendingDirection.x, pendingDirection.y)
impulseDirection =
directionLength > 0.0001
? { x: pendingDirection.x / directionLength, y: pendingDirection.y / directionLength }
: { x: 0, y: 0 }
}
uniforms.uImpulseCenter.value.set(impulseCenter.x, impulseCenter.y)
uniforms.uImpulseDirection.value.set(impulseDirection.x, impulseDirection.y)
uniforms.uImpulseOffset.value = 56 * translation
uniforms.uImpulseSpeed.value = pendingSpeed
// 质量档把额外预算用于场分辨率和衰减细节;输入范围保持稳定,避免高质量改变动态效果的空间语义。
uniforms.uImpulseSigma.value = mix(54, 97.2, translation)
uniforms.uPropagation.value = Math.min(
0.18,
Math.max(0.08, basePropagation * (referenceCssPerTexel / targetCssPerTexel) ** 2),
)
uniforms.uRestoring.value = quality === 'high' ? 0.028 : 0.035
uniforms.uStep.value = stepMs / MAX_STEP_MS
uniforms.uVelocityDecay.value = 2 ** (-decayStepMs / velocityHalfLife)
uniforms.uHeightDecay.value = 2 ** (-decayStepMs / heightHalfLife)
uniforms.uEnergyDecay.value = 2 ** (-decayStepMs / energyHalfLife)
for (let index = 0; index < substeps; index += 1) {
uniforms.uPrevious.value = readTarget.texture
uniforms.uImpulse.value = index === 0 ? pendingImpulse : 0
renderTarget(writeTarget)
const previousReadTarget = readTarget
readTarget = writeTarget
writeTarget = previousReadTarget
}
pendingImpulse = 0
pendingSpeed = 0
pendingDirection = { x: 0, y: 0 }
lastStepAt = timestamp
if (flow <= 0) clearOnNextFrame = true
return true
},
}
}