mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-08-22 08:52:21 +08:00
feat(glass): add selectable dynamics modes (#639)
This commit is contained in:
187
src/rendering/glass/__tests__/glassFluidDynamics.spec.ts
Normal file
187
src/rendering/glass/__tests__/glassFluidDynamics.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
385
src/rendering/glass/__tests__/glassRippleDynamics.spec.ts
Normal file
385
src/rendering/glass/__tests__/glassRippleDynamics.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
335
src/rendering/glass/glassFluidDynamics.ts
Normal file
335
src/rendering/glass/glassFluidDynamics.ts
Normal 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
|
||||
},
|
||||
}
|
||||
}
|
||||
480
src/rendering/glass/glassRippleDynamics.ts
Normal file
480
src/rendering/glass/glassRippleDynamics.ts
Normal 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
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user