mirror of
https://github.com/DavidHDev/vue-bits.git
synced 2026-03-07 22:49:31 -07:00
1266 lines
37 KiB
Vue
1266 lines
37 KiB
Vue
<template>
|
|
<div ref="mountRef" :class="`w-full h-full relative overflow-hidden ${className || ''}`" :style="style" />
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import * as THREE from 'three';
|
|
import { onMounted, onUnmounted, ref, watch } from 'vue';
|
|
|
|
interface LiquidEtherProps {
|
|
mouseForce?: number;
|
|
cursorSize?: number;
|
|
isViscous?: boolean;
|
|
viscous?: number;
|
|
iterationsViscous?: number;
|
|
iterationsPoisson?: number;
|
|
dt?: number;
|
|
BFECC?: boolean;
|
|
resolution?: number;
|
|
isBounce?: boolean;
|
|
colors?: string[];
|
|
style?: Record<string, any>;
|
|
className?: string;
|
|
autoDemo?: boolean;
|
|
autoSpeed?: number;
|
|
autoIntensity?: number;
|
|
takeoverDuration?: number;
|
|
autoResumeDelay?: number;
|
|
autoRampDuration?: number;
|
|
}
|
|
|
|
interface SimOptions {
|
|
iterations_poisson: number;
|
|
iterations_viscous: number;
|
|
mouse_force: number;
|
|
resolution: number;
|
|
cursor_size: number;
|
|
viscous: number;
|
|
isBounce: boolean;
|
|
dt: number;
|
|
isViscous: boolean;
|
|
BFECC: boolean;
|
|
}
|
|
|
|
interface LiquidEtherWebGL {
|
|
output?: { simulation?: { options: SimOptions; resize: () => void } };
|
|
autoDriver?: {
|
|
enabled: boolean;
|
|
speed: number;
|
|
resumeDelay: number;
|
|
rampDurationMs: number;
|
|
mouse?: { autoIntensity: number; takeoverDuration: number };
|
|
forceStop: () => void;
|
|
};
|
|
resize: () => void;
|
|
start: () => void;
|
|
pause: () => void;
|
|
dispose: () => void;
|
|
}
|
|
|
|
const props = withDefaults(defineProps<LiquidEtherProps>(), {
|
|
mouseForce: 20,
|
|
cursorSize: 100,
|
|
isViscous: false,
|
|
viscous: 30,
|
|
iterationsViscous: 32,
|
|
iterationsPoisson: 32,
|
|
dt: 0.014,
|
|
BFECC: true,
|
|
resolution: 0.5,
|
|
isBounce: false,
|
|
colors: () => ['#5227FF', '#FF9FFC', '#B19EEF'],
|
|
style: () => ({}),
|
|
className: '',
|
|
autoDemo: true,
|
|
autoSpeed: 0.5,
|
|
autoIntensity: 2.2,
|
|
takeoverDuration: 0.25,
|
|
autoResumeDelay: 1000,
|
|
autoRampDuration: 0.6
|
|
});
|
|
|
|
const mountRef = ref<HTMLDivElement | null>(null);
|
|
const webglRef = ref<LiquidEtherWebGL | null>(null);
|
|
const resizeObserverRef = ref<ResizeObserver | null>(null);
|
|
const rafRef = ref<number | null>(null);
|
|
const intersectionObserverRef = ref<IntersectionObserver | null>(null);
|
|
const isVisibleRef = ref<boolean>(true);
|
|
const resizeRafRef = ref<number | null>(null);
|
|
|
|
const initWebGL = () => {
|
|
if (!mountRef.value) return;
|
|
|
|
function makePaletteTexture(stops: string[]): THREE.DataTexture {
|
|
let arr: string[];
|
|
if (Array.isArray(stops) && stops.length > 0) {
|
|
arr = stops.length === 1 ? [stops[0], stops[0]] : stops;
|
|
} else {
|
|
arr = ['#ffffff', '#ffffff'];
|
|
}
|
|
const w = arr.length;
|
|
const data = new Uint8Array(w * 4);
|
|
for (let i = 0; i < w; i++) {
|
|
const c = new THREE.Color(arr[i]);
|
|
data[i * 4 + 0] = Math.round(c.r * 255);
|
|
data[i * 4 + 1] = Math.round(c.g * 255);
|
|
data[i * 4 + 2] = Math.round(c.b * 255);
|
|
data[i * 4 + 3] = 255;
|
|
}
|
|
const tex = new THREE.DataTexture(data, w, 1, THREE.RGBAFormat);
|
|
tex.magFilter = THREE.LinearFilter;
|
|
tex.minFilter = THREE.LinearFilter;
|
|
tex.wrapS = THREE.ClampToEdgeWrapping;
|
|
tex.wrapT = THREE.ClampToEdgeWrapping;
|
|
tex.generateMipmaps = false;
|
|
tex.needsUpdate = true;
|
|
return tex;
|
|
}
|
|
|
|
const paletteTex = makePaletteTexture(props.colors);
|
|
const bgVec4 = new THREE.Vector4(0, 0, 0, 0);
|
|
|
|
class CommonClass {
|
|
width = 0;
|
|
height = 0;
|
|
aspect = 1;
|
|
pixelRatio = 1;
|
|
isMobile = false;
|
|
breakpoint = 768;
|
|
fboWidth: number | null = null;
|
|
fboHeight: number | null = null;
|
|
time = 0;
|
|
delta = 0;
|
|
container: HTMLElement | null = null;
|
|
renderer: THREE.WebGLRenderer | null = null;
|
|
clock: THREE.Clock | null = null;
|
|
|
|
init(container: HTMLElement) {
|
|
this.container = container;
|
|
this.pixelRatio = Math.min(window.devicePixelRatio || 1, 2);
|
|
this.resize();
|
|
this.renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
|
|
this.renderer.autoClear = false;
|
|
this.renderer.setClearColor(new THREE.Color(0x000000), 0);
|
|
this.renderer.setPixelRatio(this.pixelRatio);
|
|
this.renderer.setSize(this.width, this.height);
|
|
const el = this.renderer.domElement;
|
|
el.style.width = '100%';
|
|
el.style.height = '100%';
|
|
el.style.display = 'block';
|
|
this.clock = new THREE.Clock();
|
|
this.clock.start();
|
|
}
|
|
|
|
resize() {
|
|
if (!this.container) return;
|
|
const rect = this.container.getBoundingClientRect();
|
|
this.width = Math.max(1, Math.floor(rect.width));
|
|
this.height = Math.max(1, Math.floor(rect.height));
|
|
this.aspect = this.width / this.height;
|
|
if (this.renderer) this.renderer.setSize(this.width, this.height, false);
|
|
}
|
|
|
|
update() {
|
|
if (!this.clock) return;
|
|
this.delta = this.clock.getDelta();
|
|
this.time += this.delta;
|
|
}
|
|
}
|
|
const Common = new CommonClass();
|
|
|
|
class MouseClass {
|
|
mouseMoved = false;
|
|
coords = new THREE.Vector2();
|
|
coords_old = new THREE.Vector2();
|
|
diff = new THREE.Vector2();
|
|
timer: number | null = null;
|
|
container: HTMLElement | null = null;
|
|
isHoverInside = false;
|
|
hasUserControl = false;
|
|
isAutoActive = false;
|
|
autoIntensity = 2.0;
|
|
takeoverActive = false;
|
|
takeoverStartTime = 0;
|
|
takeoverDuration = 0.25;
|
|
takeoverFrom = new THREE.Vector2();
|
|
takeoverTo = new THREE.Vector2();
|
|
onInteract: (() => void) | null = null;
|
|
private _onMouseMove = this.onDocumentMouseMove.bind(this);
|
|
private _onTouchStart = this.onDocumentTouchStart.bind(this);
|
|
private _onTouchMove = this.onDocumentTouchMove.bind(this);
|
|
private _onMouseEnter = this.onMouseEnter.bind(this);
|
|
private _onMouseLeave = this.onMouseLeave.bind(this);
|
|
private _onTouchEnd = this.onTouchEnd.bind(this);
|
|
|
|
init(container: HTMLElement) {
|
|
this.container = container;
|
|
container.addEventListener('mousemove', this._onMouseMove);
|
|
container.addEventListener('touchstart', this._onTouchStart, { passive: true });
|
|
container.addEventListener('touchmove', this._onTouchMove, { passive: true });
|
|
container.addEventListener('mouseenter', this._onMouseEnter);
|
|
container.addEventListener('mouseleave', this._onMouseLeave);
|
|
container.addEventListener('touchend', this._onTouchEnd);
|
|
}
|
|
|
|
dispose() {
|
|
const c = this.container;
|
|
if (!c) return;
|
|
c.removeEventListener('mousemove', this._onMouseMove);
|
|
c.removeEventListener('touchstart', this._onTouchStart);
|
|
c.removeEventListener('touchmove', this._onTouchMove);
|
|
c.removeEventListener('mouseenter', this._onMouseEnter);
|
|
c.removeEventListener('mouseleave', this._onMouseLeave);
|
|
c.removeEventListener('touchend', this._onTouchEnd);
|
|
}
|
|
|
|
setCoords(x: number, y: number) {
|
|
if (!this.container) return;
|
|
if (this.timer) window.clearTimeout(this.timer);
|
|
const rect = this.container.getBoundingClientRect();
|
|
const nx = (x - rect.left) / rect.width;
|
|
const ny = (y - rect.top) / rect.height;
|
|
this.coords.set(nx * 2 - 1, -(ny * 2 - 1));
|
|
this.mouseMoved = true;
|
|
this.timer = window.setTimeout(() => {
|
|
this.mouseMoved = false;
|
|
}, 100);
|
|
}
|
|
|
|
setNormalized(nx: number, ny: number) {
|
|
this.coords.set(nx, ny);
|
|
this.mouseMoved = true;
|
|
}
|
|
|
|
onDocumentMouseMove(event: MouseEvent) {
|
|
if (this.onInteract) this.onInteract();
|
|
if (this.isAutoActive && !this.hasUserControl && !this.takeoverActive) {
|
|
if (!this.container) return;
|
|
const rect = this.container.getBoundingClientRect();
|
|
const nx = (event.clientX - rect.left) / rect.width;
|
|
const ny = (event.clientY - rect.top) / rect.height;
|
|
this.takeoverFrom.copy(this.coords);
|
|
this.takeoverTo.set(nx * 2 - 1, -(ny * 2 - 1));
|
|
this.takeoverStartTime = performance.now();
|
|
this.takeoverActive = true;
|
|
this.hasUserControl = true;
|
|
this.isAutoActive = false;
|
|
return;
|
|
}
|
|
this.setCoords(event.clientX, event.clientY);
|
|
this.hasUserControl = true;
|
|
}
|
|
|
|
onDocumentTouchStart(event: TouchEvent) {
|
|
if (event.touches.length === 1) {
|
|
const t = event.touches[0];
|
|
if (this.onInteract) this.onInteract();
|
|
this.setCoords(t.pageX, t.pageY);
|
|
this.hasUserControl = true;
|
|
}
|
|
}
|
|
|
|
onDocumentTouchMove(event: TouchEvent) {
|
|
if (event.touches.length === 1) {
|
|
const t = event.touches[0];
|
|
if (this.onInteract) this.onInteract();
|
|
this.setCoords(t.pageX, t.pageY);
|
|
}
|
|
}
|
|
|
|
onTouchEnd() {
|
|
this.isHoverInside = false;
|
|
}
|
|
|
|
onMouseEnter() {
|
|
this.isHoverInside = true;
|
|
}
|
|
|
|
onMouseLeave() {
|
|
this.isHoverInside = false;
|
|
}
|
|
|
|
update() {
|
|
if (this.takeoverActive) {
|
|
const t = (performance.now() - this.takeoverStartTime) / (this.takeoverDuration * 1000);
|
|
if (t >= 1) {
|
|
this.takeoverActive = false;
|
|
this.coords.copy(this.takeoverTo);
|
|
this.coords_old.copy(this.coords);
|
|
this.diff.set(0, 0);
|
|
} else {
|
|
const k = t * t * (3 - 2 * t);
|
|
this.coords.copy(this.takeoverFrom).lerp(this.takeoverTo, k);
|
|
}
|
|
}
|
|
this.diff.subVectors(this.coords, this.coords_old);
|
|
this.coords_old.copy(this.coords);
|
|
if (this.coords_old.x === 0 && this.coords_old.y === 0) this.diff.set(0, 0);
|
|
if (this.isAutoActive && !this.takeoverActive) this.diff.multiplyScalar(this.autoIntensity);
|
|
}
|
|
}
|
|
const Mouse = new MouseClass();
|
|
|
|
class AutoDriver {
|
|
mouse: MouseClass;
|
|
manager: WebGLManager;
|
|
enabled: boolean;
|
|
speed: number;
|
|
resumeDelay: number;
|
|
rampDurationMs: number;
|
|
active = false;
|
|
current = new THREE.Vector2(0, 0);
|
|
target = new THREE.Vector2();
|
|
lastTime = performance.now();
|
|
activationTime = 0;
|
|
margin = 0.2;
|
|
private _tmpDir = new THREE.Vector2();
|
|
|
|
constructor(
|
|
mouse: MouseClass,
|
|
manager: WebGLManager,
|
|
opts: { enabled: boolean; speed: number; resumeDelay: number; rampDuration: number }
|
|
) {
|
|
this.mouse = mouse;
|
|
this.manager = manager;
|
|
this.enabled = opts.enabled;
|
|
this.speed = opts.speed;
|
|
this.resumeDelay = opts.resumeDelay || 3000;
|
|
this.rampDurationMs = (opts.rampDuration || 0) * 1000;
|
|
this.pickNewTarget();
|
|
}
|
|
|
|
pickNewTarget() {
|
|
const r = Math.random;
|
|
this.target.set((r() * 2 - 1) * (1 - this.margin), (r() * 2 - 1) * (1 - this.margin));
|
|
}
|
|
|
|
forceStop() {
|
|
this.active = false;
|
|
this.mouse.isAutoActive = false;
|
|
}
|
|
|
|
update() {
|
|
if (!this.enabled) return;
|
|
const now = performance.now();
|
|
const idle = now - this.manager.lastUserInteraction;
|
|
if (idle < this.resumeDelay) {
|
|
if (this.active) this.forceStop();
|
|
return;
|
|
}
|
|
if (this.mouse.isHoverInside) {
|
|
if (this.active) this.forceStop();
|
|
return;
|
|
}
|
|
if (!this.active) {
|
|
this.active = true;
|
|
this.current.copy(this.mouse.coords);
|
|
this.lastTime = now;
|
|
this.activationTime = now;
|
|
}
|
|
if (!this.active) return;
|
|
this.mouse.isAutoActive = true;
|
|
let dtSec = (now - this.lastTime) / 1000;
|
|
this.lastTime = now;
|
|
if (dtSec > 0.2) dtSec = 0.016;
|
|
const dir = this._tmpDir.subVectors(this.target, this.current);
|
|
const dist = dir.length();
|
|
if (dist < 0.01) {
|
|
this.pickNewTarget();
|
|
return;
|
|
}
|
|
dir.normalize();
|
|
let ramp = 1;
|
|
if (this.rampDurationMs > 0) {
|
|
const t = Math.min(1, (now - this.activationTime) / this.rampDurationMs);
|
|
ramp = t * t * (3 - 2 * t);
|
|
}
|
|
const step = this.speed * dtSec * ramp;
|
|
const move = Math.min(step, dist);
|
|
this.current.addScaledVector(dir, move);
|
|
this.mouse.setNormalized(this.current.x, this.current.y);
|
|
}
|
|
}
|
|
|
|
// Shader code
|
|
const face_vert = `
|
|
attribute vec3 position;
|
|
uniform vec2 px;
|
|
uniform vec2 boundarySpace;
|
|
varying vec2 uv;
|
|
precision highp float;
|
|
void main(){
|
|
vec3 pos = position;
|
|
vec2 scale = 1.0 - boundarySpace * 2.0;
|
|
pos.xy = pos.xy * scale;
|
|
uv = vec2(0.5)+(pos.xy)*0.5;
|
|
gl_Position = vec4(pos, 1.0);
|
|
}
|
|
`;
|
|
const line_vert = `
|
|
attribute vec3 position;
|
|
uniform vec2 px;
|
|
precision highp float;
|
|
varying vec2 uv;
|
|
void main(){
|
|
vec3 pos = position;
|
|
uv = 0.5 + pos.xy * 0.5;
|
|
vec2 n = sign(pos.xy);
|
|
pos.xy = abs(pos.xy) - px * 1.0;
|
|
pos.xy *= n;
|
|
gl_Position = vec4(pos, 1.0);
|
|
}
|
|
`;
|
|
const mouse_vert = `
|
|
precision highp float;
|
|
attribute vec3 position;
|
|
attribute vec2 uv;
|
|
uniform vec2 center;
|
|
uniform vec2 scale;
|
|
uniform vec2 px;
|
|
varying vec2 vUv;
|
|
void main(){
|
|
vec2 pos = position.xy * scale * 2.0 * px + center;
|
|
vUv = uv;
|
|
gl_Position = vec4(pos, 0.0, 1.0);
|
|
}
|
|
`;
|
|
const advection_frag = `
|
|
precision highp float;
|
|
uniform sampler2D velocity;
|
|
uniform float dt;
|
|
uniform bool isBFECC;
|
|
uniform vec2 fboSize;
|
|
uniform vec2 px;
|
|
varying vec2 uv;
|
|
void main(){
|
|
vec2 ratio = max(fboSize.x, fboSize.y) / fboSize;
|
|
if(isBFECC == false){
|
|
vec2 vel = texture2D(velocity, uv).xy;
|
|
vec2 uv2 = uv - vel * dt * ratio;
|
|
vec2 newVel = texture2D(velocity, uv2).xy;
|
|
gl_FragColor = vec4(newVel, 0.0, 0.0);
|
|
} else {
|
|
vec2 spot_new = uv;
|
|
vec2 vel_old = texture2D(velocity, uv).xy;
|
|
vec2 spot_old = spot_new - vel_old * dt * ratio;
|
|
vec2 vel_new1 = texture2D(velocity, spot_old).xy;
|
|
vec2 spot_new2 = spot_old + vel_new1 * dt * ratio;
|
|
vec2 error = spot_new2 - spot_new;
|
|
vec2 spot_new3 = spot_new - error / 2.0;
|
|
vec2 vel_2 = texture2D(velocity, spot_new3).xy;
|
|
vec2 spot_old2 = spot_new3 - vel_2 * dt * ratio;
|
|
vec2 newVel2 = texture2D(velocity, spot_old2).xy;
|
|
gl_FragColor = vec4(newVel2, 0.0, 0.0);
|
|
}
|
|
}
|
|
`;
|
|
const color_frag = `
|
|
precision highp float;
|
|
uniform sampler2D velocity;
|
|
uniform sampler2D palette;
|
|
uniform vec4 bgColor;
|
|
varying vec2 uv;
|
|
void main(){
|
|
vec2 vel = texture2D(velocity, uv).xy;
|
|
float lenv = clamp(length(vel), 0.0, 1.0);
|
|
vec3 c = texture2D(palette, vec2(lenv, 0.5)).rgb;
|
|
vec3 outRGB = mix(bgColor.rgb, c, lenv);
|
|
float outA = mix(bgColor.a, 1.0, lenv);
|
|
gl_FragColor = vec4(outRGB, outA);
|
|
}
|
|
`;
|
|
const divergence_frag = `
|
|
precision highp float;
|
|
uniform sampler2D velocity;
|
|
uniform float dt;
|
|
uniform vec2 px;
|
|
varying vec2 uv;
|
|
void main(){
|
|
float x0 = texture2D(velocity, uv-vec2(px.x, 0.0)).x;
|
|
float x1 = texture2D(velocity, uv+vec2(px.x, 0.0)).x;
|
|
float y0 = texture2D(velocity, uv-vec2(0.0, px.y)).y;
|
|
float y1 = texture2D(velocity, uv+vec2(0.0, px.y)).y;
|
|
float divergence = (x1 - x0 + y1 - y0) / 2.0;
|
|
gl_FragColor = vec4(divergence / dt);
|
|
}
|
|
`;
|
|
const externalForce_frag = `
|
|
precision highp float;
|
|
uniform vec2 force;
|
|
uniform vec2 center;
|
|
uniform vec2 scale;
|
|
uniform vec2 px;
|
|
varying vec2 vUv;
|
|
void main(){
|
|
vec2 circle = (vUv - 0.5) * 2.0;
|
|
float d = 1.0 - min(length(circle), 1.0);
|
|
d *= d;
|
|
gl_FragColor = vec4(force * d, 0.0, 1.0);
|
|
}
|
|
`;
|
|
const poisson_frag = `
|
|
precision highp float;
|
|
uniform sampler2D pressure;
|
|
uniform sampler2D divergence;
|
|
uniform vec2 px;
|
|
varying vec2 uv;
|
|
void main(){
|
|
float p0 = texture2D(pressure, uv + vec2(px.x * 2.0, 0.0)).r;
|
|
float p1 = texture2D(pressure, uv - vec2(px.x * 2.0, 0.0)).r;
|
|
float p2 = texture2D(pressure, uv + vec2(0.0, px.y * 2.0)).r;
|
|
float p3 = texture2D(pressure, uv - vec2(0.0, px.y * 2.0)).r;
|
|
float div = texture2D(divergence, uv).r;
|
|
float newP = (p0 + p1 + p2 + p3) / 4.0 - div;
|
|
gl_FragColor = vec4(newP);
|
|
}
|
|
`;
|
|
const pressure_frag = `
|
|
precision highp float;
|
|
uniform sampler2D pressure;
|
|
uniform sampler2D velocity;
|
|
uniform vec2 px;
|
|
uniform float dt;
|
|
varying vec2 uv;
|
|
void main(){
|
|
float step = 1.0;
|
|
float p0 = texture2D(pressure, uv + vec2(px.x * step, 0.0)).r;
|
|
float p1 = texture2D(pressure, uv - vec2(px.x * step, 0.0)).r;
|
|
float p2 = texture2D(pressure, uv + vec2(0.0, px.y * step)).r;
|
|
float p3 = texture2D(pressure, uv - vec2(0.0, px.y * step)).r;
|
|
vec2 v = texture2D(velocity, uv).xy;
|
|
vec2 gradP = vec2(p0 - p1, p2 - p3) * 0.5;
|
|
v = v - gradP * dt;
|
|
gl_FragColor = vec4(v, 0.0, 1.0);
|
|
}
|
|
`;
|
|
const viscous_frag = `
|
|
precision highp float;
|
|
uniform sampler2D velocity;
|
|
uniform sampler2D velocity_new;
|
|
uniform float v;
|
|
uniform vec2 px;
|
|
uniform float dt;
|
|
varying vec2 uv;
|
|
void main(){
|
|
vec2 old = texture2D(velocity, uv).xy;
|
|
vec2 new0 = texture2D(velocity_new, uv + vec2(px.x * 2.0, 0.0)).xy;
|
|
vec2 new1 = texture2D(velocity_new, uv - vec2(px.x * 2.0, 0.0)).xy;
|
|
vec2 new2 = texture2D(velocity_new, uv + vec2(0.0, px.y * 2.0)).xy;
|
|
vec2 new3 = texture2D(velocity_new, uv - vec2(0.0, px.y * 2.0)).xy;
|
|
vec2 newv = 4.0 * old + v * dt * (new0 + new1 + new2 + new3);
|
|
newv /= 4.0 * (1.0 + v * dt);
|
|
gl_FragColor = vec4(newv, 0.0, 0.0);
|
|
}
|
|
`;
|
|
|
|
type Uniforms = Record<string, { value: any }>;
|
|
|
|
class ShaderPass {
|
|
props: any;
|
|
uniforms?: Uniforms;
|
|
scene: THREE.Scene | null = null;
|
|
camera: THREE.Camera | null = null;
|
|
material: THREE.RawShaderMaterial | null = null;
|
|
geometry: THREE.BufferGeometry | null = null;
|
|
plane: THREE.Mesh | null = null;
|
|
|
|
constructor(props: any) {
|
|
this.props = props || {};
|
|
this.uniforms = this.props.material?.uniforms;
|
|
}
|
|
|
|
init(..._args: any[]) {
|
|
this.scene = new THREE.Scene();
|
|
this.camera = new THREE.Camera();
|
|
if (this.uniforms) {
|
|
this.material = new THREE.RawShaderMaterial(this.props.material);
|
|
this.geometry = new THREE.PlaneGeometry(2, 2);
|
|
this.plane = new THREE.Mesh(this.geometry, this.material);
|
|
this.scene.add(this.plane);
|
|
}
|
|
}
|
|
|
|
update(..._args: any[]) {
|
|
if (!Common.renderer || !this.scene || !this.camera) return;
|
|
Common.renderer.setRenderTarget(this.props.output || null);
|
|
Common.renderer.render(this.scene, this.camera);
|
|
Common.renderer.setRenderTarget(null);
|
|
}
|
|
}
|
|
|
|
// Shader pass classes (Advection, ExternalForce, etc.) - keeping them the same as in React version
|
|
class Advection extends ShaderPass {
|
|
line!: THREE.LineSegments;
|
|
|
|
constructor(simProps: any) {
|
|
super({
|
|
material: {
|
|
vertexShader: face_vert,
|
|
fragmentShader: advection_frag,
|
|
uniforms: {
|
|
boundarySpace: { value: simProps.cellScale },
|
|
px: { value: simProps.cellScale },
|
|
fboSize: { value: simProps.fboSize },
|
|
velocity: { value: simProps.src.texture },
|
|
dt: { value: simProps.dt },
|
|
isBFECC: { value: true }
|
|
}
|
|
},
|
|
output: simProps.dst
|
|
});
|
|
this.uniforms = this.props.material.uniforms;
|
|
this.init();
|
|
}
|
|
|
|
init() {
|
|
super.init();
|
|
this.createBoundary();
|
|
}
|
|
|
|
createBoundary() {
|
|
const boundaryG = new THREE.BufferGeometry();
|
|
const vertices_boundary = new Float32Array([
|
|
-1, -1, 0, -1, 1, 0, -1, 1, 0, 1, 1, 0, 1, 1, 0, 1, -1, 0, 1, -1, 0, -1, -1, 0
|
|
]);
|
|
boundaryG.setAttribute('position', new THREE.BufferAttribute(vertices_boundary, 3));
|
|
const boundaryM = new THREE.RawShaderMaterial({
|
|
vertexShader: line_vert,
|
|
fragmentShader: advection_frag,
|
|
uniforms: this.uniforms!
|
|
});
|
|
this.line = new THREE.LineSegments(boundaryG, boundaryM);
|
|
this.scene!.add(this.line);
|
|
}
|
|
|
|
update(...args: any[]) {
|
|
const { dt, isBounce, BFECC } = (args[0] || {}) as { dt?: number; isBounce?: boolean; BFECC?: boolean };
|
|
if (!this.uniforms) return;
|
|
if (typeof dt === 'number') this.uniforms.dt.value = dt;
|
|
if (typeof isBounce === 'boolean') this.line.visible = isBounce;
|
|
if (typeof BFECC === 'boolean') this.uniforms.isBFECC.value = BFECC;
|
|
super.update();
|
|
}
|
|
}
|
|
|
|
class ExternalForce extends ShaderPass {
|
|
mouse!: THREE.Mesh;
|
|
|
|
constructor(simProps: any) {
|
|
super({ output: simProps.dst });
|
|
this.init(simProps);
|
|
}
|
|
|
|
init(simProps: any) {
|
|
super.init();
|
|
const mouseG = new THREE.PlaneGeometry(1, 1);
|
|
const mouseM = new THREE.RawShaderMaterial({
|
|
vertexShader: mouse_vert,
|
|
fragmentShader: externalForce_frag,
|
|
blending: THREE.AdditiveBlending,
|
|
depthWrite: false,
|
|
uniforms: {
|
|
px: { value: simProps.cellScale },
|
|
force: { value: new THREE.Vector2(0, 0) },
|
|
center: { value: new THREE.Vector2(0, 0) },
|
|
scale: { value: new THREE.Vector2(simProps.cursor_size, simProps.cursor_size) }
|
|
}
|
|
});
|
|
this.mouse = new THREE.Mesh(mouseG, mouseM);
|
|
this.scene!.add(this.mouse);
|
|
}
|
|
|
|
update(...args: any[]) {
|
|
const props = args[0] || {};
|
|
const forceX = (Mouse.diff.x / 2) * (props.mouse_force || 0);
|
|
const forceY = (Mouse.diff.y / 2) * (props.mouse_force || 0);
|
|
const cellScale = props.cellScale || { x: 1, y: 1 };
|
|
const cursorSize = props.cursor_size || 0;
|
|
const cursorSizeX = cursorSize * cellScale.x;
|
|
const cursorSizeY = cursorSize * cellScale.y;
|
|
const centerX = Math.min(
|
|
Math.max(Mouse.coords.x, -1 + cursorSizeX + cellScale.x * 2),
|
|
1 - cursorSizeX - cellScale.x * 2
|
|
);
|
|
const centerY = Math.min(
|
|
Math.max(Mouse.coords.y, -1 + cursorSizeY + cellScale.y * 2),
|
|
1 - cursorSizeY - cellScale.y * 2
|
|
);
|
|
const uniforms = (this.mouse.material as THREE.RawShaderMaterial).uniforms;
|
|
uniforms.force.value.set(forceX, forceY);
|
|
uniforms.center.value.set(centerX, centerY);
|
|
uniforms.scale.value.set(cursorSize, cursorSize);
|
|
super.update();
|
|
}
|
|
}
|
|
|
|
class Viscous extends ShaderPass {
|
|
constructor(simProps: any) {
|
|
super({
|
|
material: {
|
|
vertexShader: face_vert,
|
|
fragmentShader: viscous_frag,
|
|
uniforms: {
|
|
boundarySpace: { value: simProps.boundarySpace },
|
|
velocity: { value: simProps.src.texture },
|
|
velocity_new: { value: simProps.dst_.texture },
|
|
v: { value: simProps.viscous },
|
|
px: { value: simProps.cellScale },
|
|
dt: { value: simProps.dt }
|
|
}
|
|
},
|
|
output: simProps.dst,
|
|
output0: simProps.dst_,
|
|
output1: simProps.dst
|
|
});
|
|
this.init();
|
|
}
|
|
|
|
update(...args: any[]) {
|
|
const { viscous, iterations, dt } = (args[0] || {}) as { viscous?: number; iterations?: number; dt?: number };
|
|
if (!this.uniforms) return;
|
|
let fbo_in: any, fbo_out: any;
|
|
if (typeof viscous === 'number') this.uniforms.v.value = viscous;
|
|
const iter = iterations ?? 0;
|
|
for (let i = 0; i < iter; i++) {
|
|
if (i % 2 === 0) {
|
|
fbo_in = this.props.output0;
|
|
fbo_out = this.props.output1;
|
|
} else {
|
|
fbo_in = this.props.output1;
|
|
fbo_out = this.props.output0;
|
|
}
|
|
this.uniforms.velocity_new.value = fbo_in.texture;
|
|
this.props.output = fbo_out;
|
|
if (typeof dt === 'number') this.uniforms.dt.value = dt;
|
|
super.update();
|
|
}
|
|
return fbo_out;
|
|
}
|
|
}
|
|
|
|
class Divergence extends ShaderPass {
|
|
constructor(simProps: any) {
|
|
super({
|
|
material: {
|
|
vertexShader: face_vert,
|
|
fragmentShader: divergence_frag,
|
|
uniforms: {
|
|
boundarySpace: { value: simProps.boundarySpace },
|
|
velocity: { value: simProps.src.texture },
|
|
px: { value: simProps.cellScale },
|
|
dt: { value: simProps.dt }
|
|
}
|
|
},
|
|
output: simProps.dst
|
|
});
|
|
this.init();
|
|
}
|
|
|
|
update(...args: any[]) {
|
|
const { vel } = (args[0] || {}) as { vel?: any };
|
|
if (this.uniforms && vel) {
|
|
this.uniforms.velocity.value = vel.texture;
|
|
}
|
|
super.update();
|
|
}
|
|
}
|
|
|
|
class Poisson extends ShaderPass {
|
|
constructor(simProps: any) {
|
|
super({
|
|
material: {
|
|
vertexShader: face_vert,
|
|
fragmentShader: poisson_frag,
|
|
uniforms: {
|
|
boundarySpace: { value: simProps.boundarySpace },
|
|
pressure: { value: simProps.dst_.texture },
|
|
divergence: { value: simProps.src.texture },
|
|
px: { value: simProps.cellScale }
|
|
}
|
|
},
|
|
output: simProps.dst,
|
|
output0: simProps.dst_,
|
|
output1: simProps.dst
|
|
});
|
|
this.init();
|
|
}
|
|
|
|
update(...args: any[]) {
|
|
const { iterations } = (args[0] || {}) as { iterations?: number };
|
|
let p_in: any, p_out: any;
|
|
const iter = iterations ?? 0;
|
|
for (let i = 0; i < iter; i++) {
|
|
if (i % 2 === 0) {
|
|
p_in = this.props.output0;
|
|
p_out = this.props.output1;
|
|
} else {
|
|
p_in = this.props.output1;
|
|
p_out = this.props.output0;
|
|
}
|
|
if (this.uniforms) this.uniforms.pressure.value = p_in.texture;
|
|
this.props.output = p_out;
|
|
super.update();
|
|
}
|
|
return p_out;
|
|
}
|
|
}
|
|
|
|
class Pressure extends ShaderPass {
|
|
constructor(simProps: any) {
|
|
super({
|
|
material: {
|
|
vertexShader: face_vert,
|
|
fragmentShader: pressure_frag,
|
|
uniforms: {
|
|
boundarySpace: { value: simProps.boundarySpace },
|
|
pressure: { value: simProps.src_p.texture },
|
|
velocity: { value: simProps.src_v.texture },
|
|
px: { value: simProps.cellScale },
|
|
dt: { value: simProps.dt }
|
|
}
|
|
},
|
|
output: simProps.dst
|
|
});
|
|
this.init();
|
|
}
|
|
|
|
update(...args: any[]) {
|
|
const { vel, pressure } = (args[0] || {}) as { vel?: any; pressure?: any };
|
|
if (this.uniforms && vel && pressure) {
|
|
this.uniforms.velocity.value = vel.texture;
|
|
this.uniforms.pressure.value = pressure.texture;
|
|
}
|
|
super.update();
|
|
}
|
|
}
|
|
|
|
class Simulation {
|
|
options: SimOptions;
|
|
fbos: Record<string, THREE.WebGLRenderTarget | null> = {
|
|
vel_0: null,
|
|
vel_1: null,
|
|
vel_viscous0: null,
|
|
vel_viscous1: null,
|
|
div: null,
|
|
pressure_0: null,
|
|
pressure_1: null
|
|
};
|
|
fboSize = new THREE.Vector2();
|
|
cellScale = new THREE.Vector2();
|
|
boundarySpace = new THREE.Vector2();
|
|
advection!: Advection;
|
|
externalForce!: ExternalForce;
|
|
viscous!: Viscous;
|
|
divergence!: Divergence;
|
|
poisson!: Poisson;
|
|
pressure!: Pressure;
|
|
|
|
constructor(options?: Partial<SimOptions>) {
|
|
this.options = {
|
|
iterations_poisson: 32,
|
|
iterations_viscous: 32,
|
|
mouse_force: 20,
|
|
resolution: 0.5,
|
|
cursor_size: 100,
|
|
viscous: 30,
|
|
isBounce: false,
|
|
dt: 0.014,
|
|
isViscous: false,
|
|
BFECC: true,
|
|
...options
|
|
};
|
|
this.init();
|
|
}
|
|
|
|
init() {
|
|
this.calcSize();
|
|
this.createAllFBO();
|
|
this.createShaderPass();
|
|
}
|
|
|
|
getFloatType() {
|
|
const isIOS = /(iPad|iPhone|iPod)/i.test(navigator.userAgent);
|
|
return isIOS ? THREE.HalfFloatType : THREE.FloatType;
|
|
}
|
|
|
|
createAllFBO() {
|
|
const type = this.getFloatType();
|
|
const opts = {
|
|
type,
|
|
depthBuffer: false,
|
|
stencilBuffer: false,
|
|
minFilter: THREE.LinearFilter,
|
|
magFilter: THREE.LinearFilter,
|
|
wrapS: THREE.ClampToEdgeWrapping,
|
|
wrapT: THREE.ClampToEdgeWrapping
|
|
} as const;
|
|
for (const key in this.fbos) {
|
|
this.fbos[key] = new THREE.WebGLRenderTarget(this.fboSize.x, this.fboSize.y, opts);
|
|
}
|
|
}
|
|
|
|
createShaderPass() {
|
|
this.advection = new Advection({
|
|
cellScale: this.cellScale,
|
|
fboSize: this.fboSize,
|
|
dt: this.options.dt,
|
|
src: this.fbos.vel_0,
|
|
dst: this.fbos.vel_1
|
|
});
|
|
this.externalForce = new ExternalForce({
|
|
cellScale: this.cellScale,
|
|
cursor_size: this.options.cursor_size,
|
|
dst: this.fbos.vel_1
|
|
});
|
|
this.viscous = new Viscous({
|
|
cellScale: this.cellScale,
|
|
boundarySpace: this.boundarySpace,
|
|
viscous: this.options.viscous,
|
|
src: this.fbos.vel_1,
|
|
dst: this.fbos.vel_viscous1,
|
|
dst_: this.fbos.vel_viscous0,
|
|
dt: this.options.dt
|
|
});
|
|
this.divergence = new Divergence({
|
|
cellScale: this.cellScale,
|
|
boundarySpace: this.boundarySpace,
|
|
src: this.fbos.vel_viscous0,
|
|
dst: this.fbos.div,
|
|
dt: this.options.dt
|
|
});
|
|
this.poisson = new Poisson({
|
|
cellScale: this.cellScale,
|
|
boundarySpace: this.boundarySpace,
|
|
src: this.fbos.div,
|
|
dst: this.fbos.pressure_1,
|
|
dst_: this.fbos.pressure_0
|
|
});
|
|
this.pressure = new Pressure({
|
|
cellScale: this.cellScale,
|
|
boundarySpace: this.boundarySpace,
|
|
src_p: this.fbos.pressure_0,
|
|
src_v: this.fbos.vel_viscous0,
|
|
dst: this.fbos.vel_0,
|
|
dt: this.options.dt
|
|
});
|
|
}
|
|
|
|
calcSize() {
|
|
const width = Math.max(1, Math.round(this.options.resolution * Common.width));
|
|
const height = Math.max(1, Math.round(this.options.resolution * Common.height));
|
|
this.cellScale.set(1 / width, 1 / height);
|
|
this.fboSize.set(width, height);
|
|
}
|
|
|
|
resize() {
|
|
this.calcSize();
|
|
for (const key in this.fbos) {
|
|
this.fbos[key]!.setSize(this.fboSize.x, this.fboSize.y);
|
|
}
|
|
}
|
|
|
|
update() {
|
|
if (this.options.isBounce) this.boundarySpace.set(0, 0);
|
|
else this.boundarySpace.copy(this.cellScale);
|
|
this.advection.update({ dt: this.options.dt, isBounce: this.options.isBounce, BFECC: this.options.BFECC });
|
|
this.externalForce.update({
|
|
cursor_size: this.options.cursor_size,
|
|
mouse_force: this.options.mouse_force,
|
|
cellScale: this.cellScale
|
|
});
|
|
let vel: any = this.fbos.vel_1;
|
|
if (this.options.isViscous) {
|
|
vel = this.viscous.update({
|
|
viscous: this.options.viscous,
|
|
iterations: this.options.iterations_viscous,
|
|
dt: this.options.dt
|
|
});
|
|
}
|
|
this.divergence.update({ vel });
|
|
const pressure = this.poisson.update({ iterations: this.options.iterations_poisson });
|
|
this.pressure.update({ vel, pressure });
|
|
}
|
|
}
|
|
|
|
class Output {
|
|
simulation: Simulation;
|
|
scene: THREE.Scene;
|
|
camera: THREE.Camera;
|
|
output: THREE.Mesh;
|
|
|
|
constructor() {
|
|
this.simulation = new Simulation();
|
|
this.scene = new THREE.Scene();
|
|
this.camera = new THREE.Camera();
|
|
this.output = new THREE.Mesh(
|
|
new THREE.PlaneGeometry(2, 2),
|
|
new THREE.RawShaderMaterial({
|
|
vertexShader: face_vert,
|
|
fragmentShader: color_frag,
|
|
transparent: true,
|
|
depthWrite: false,
|
|
uniforms: {
|
|
velocity: { value: this.simulation.fbos.vel_0!.texture },
|
|
boundarySpace: { value: new THREE.Vector2() },
|
|
palette: { value: paletteTex },
|
|
bgColor: { value: bgVec4 }
|
|
}
|
|
})
|
|
);
|
|
this.scene.add(this.output);
|
|
}
|
|
|
|
resize() {
|
|
this.simulation.resize();
|
|
}
|
|
|
|
render() {
|
|
if (!Common.renderer) return;
|
|
Common.renderer.setRenderTarget(null);
|
|
Common.renderer.render(this.scene, this.camera);
|
|
}
|
|
|
|
update() {
|
|
this.simulation.update();
|
|
this.render();
|
|
}
|
|
}
|
|
|
|
class WebGLManager implements LiquidEtherWebGL {
|
|
props: any;
|
|
output!: Output;
|
|
autoDriver?: AutoDriver;
|
|
lastUserInteraction = performance.now();
|
|
running = false;
|
|
private _loop = this.loop.bind(this);
|
|
private _resize = this.resize.bind(this);
|
|
private _onVisibility?: () => void;
|
|
|
|
constructor(props: any) {
|
|
this.props = props;
|
|
Common.init(props.$wrapper);
|
|
Mouse.init(props.$wrapper);
|
|
Mouse.autoIntensity = props.autoIntensity;
|
|
Mouse.takeoverDuration = props.takeoverDuration;
|
|
Mouse.onInteract = () => {
|
|
this.lastUserInteraction = performance.now();
|
|
if (this.autoDriver) this.autoDriver.forceStop();
|
|
};
|
|
this.autoDriver = new AutoDriver(Mouse, this as any, {
|
|
enabled: props.autoDemo,
|
|
speed: props.autoSpeed,
|
|
resumeDelay: props.autoResumeDelay,
|
|
rampDuration: props.autoRampDuration
|
|
});
|
|
this.init();
|
|
window.addEventListener('resize', this._resize);
|
|
this._onVisibility = () => {
|
|
const hidden = document.hidden;
|
|
if (hidden) {
|
|
this.pause();
|
|
} else if (isVisibleRef.value) {
|
|
this.start();
|
|
}
|
|
};
|
|
document.addEventListener('visibilitychange', this._onVisibility);
|
|
}
|
|
|
|
init() {
|
|
if (!Common.renderer) return;
|
|
this.props.$wrapper.prepend(Common.renderer.domElement);
|
|
this.output = new Output();
|
|
}
|
|
|
|
resize() {
|
|
Common.resize();
|
|
this.output.resize();
|
|
}
|
|
|
|
render() {
|
|
if (this.autoDriver) this.autoDriver.update();
|
|
Mouse.update();
|
|
Common.update();
|
|
this.output.update();
|
|
}
|
|
|
|
loop() {
|
|
if (!this.running) return;
|
|
this.render();
|
|
rafRef.value = requestAnimationFrame(this._loop);
|
|
}
|
|
|
|
start() {
|
|
if (this.running) return;
|
|
this.running = true;
|
|
this._loop();
|
|
}
|
|
|
|
pause() {
|
|
this.running = false;
|
|
if (rafRef.value) {
|
|
cancelAnimationFrame(rafRef.value);
|
|
rafRef.value = null;
|
|
}
|
|
}
|
|
|
|
dispose() {
|
|
try {
|
|
window.removeEventListener('resize', this._resize);
|
|
if (this._onVisibility) document.removeEventListener('visibilitychange', this._onVisibility);
|
|
Mouse.dispose();
|
|
if (Common.renderer) {
|
|
const canvas = Common.renderer.domElement;
|
|
if (canvas && canvas.parentNode) canvas.parentNode.removeChild(canvas);
|
|
Common.renderer.dispose();
|
|
}
|
|
} catch {
|
|
/* noop */
|
|
}
|
|
}
|
|
}
|
|
|
|
const container = mountRef.value;
|
|
container.style.position = container.style.position || 'relative';
|
|
container.style.overflow = container.style.overflow || 'hidden';
|
|
|
|
const webgl = new WebGLManager({
|
|
$wrapper: container,
|
|
autoDemo: props.autoDemo,
|
|
autoSpeed: props.autoSpeed,
|
|
autoIntensity: props.autoIntensity,
|
|
takeoverDuration: props.takeoverDuration,
|
|
autoResumeDelay: props.autoResumeDelay,
|
|
autoRampDuration: props.autoRampDuration
|
|
});
|
|
webglRef.value = webgl;
|
|
|
|
const applyOptionsFromProps = () => {
|
|
if (!webglRef.value) return;
|
|
const sim = webglRef.value.output?.simulation;
|
|
if (!sim) return;
|
|
const prevRes = sim.options.resolution;
|
|
Object.assign(sim.options, {
|
|
mouse_force: props.mouseForce,
|
|
cursor_size: props.cursorSize,
|
|
isViscous: props.isViscous,
|
|
viscous: props.viscous,
|
|
iterations_viscous: props.iterationsViscous,
|
|
iterations_poisson: props.iterationsPoisson,
|
|
dt: props.dt,
|
|
BFECC: props.BFECC,
|
|
resolution: props.resolution,
|
|
isBounce: props.isBounce
|
|
});
|
|
if (props.resolution !== prevRes) sim.resize();
|
|
};
|
|
applyOptionsFromProps();
|
|
webgl.start();
|
|
|
|
const io = new IntersectionObserver(
|
|
entries => {
|
|
const entry = entries[0];
|
|
const isVisible = entry.isIntersecting && entry.intersectionRatio > 0;
|
|
isVisibleRef.value = isVisible;
|
|
if (!webglRef.value) return;
|
|
if (isVisible && !document.hidden) {
|
|
webglRef.value.start();
|
|
} else {
|
|
webglRef.value.pause();
|
|
}
|
|
},
|
|
{ threshold: [0, 0.01, 0.1] }
|
|
);
|
|
io.observe(container);
|
|
intersectionObserverRef.value = io;
|
|
|
|
const ro = new ResizeObserver(() => {
|
|
if (!webglRef.value) return;
|
|
if (resizeRafRef.value) cancelAnimationFrame(resizeRafRef.value);
|
|
resizeRafRef.value = requestAnimationFrame(() => {
|
|
if (!webglRef.value) return;
|
|
webglRef.value.resize();
|
|
});
|
|
});
|
|
ro.observe(container);
|
|
resizeObserverRef.value = ro;
|
|
};
|
|
|
|
// Watchers for prop changes
|
|
watch(
|
|
() => [
|
|
props.mouseForce,
|
|
props.cursorSize,
|
|
props.isViscous,
|
|
props.viscous,
|
|
props.iterationsViscous,
|
|
props.iterationsPoisson,
|
|
props.dt,
|
|
props.BFECC,
|
|
props.resolution,
|
|
props.isBounce,
|
|
props.autoDemo,
|
|
props.autoSpeed,
|
|
props.autoIntensity,
|
|
props.takeoverDuration,
|
|
props.autoResumeDelay,
|
|
props.autoRampDuration
|
|
],
|
|
() => {
|
|
const webgl = webglRef.value;
|
|
if (!webgl) return;
|
|
const sim = webgl.output?.simulation;
|
|
if (!sim) return;
|
|
const prevRes = sim.options.resolution;
|
|
Object.assign(sim.options, {
|
|
mouse_force: props.mouseForce,
|
|
cursor_size: props.cursorSize,
|
|
isViscous: props.isViscous,
|
|
viscous: props.viscous,
|
|
iterations_viscous: props.iterationsViscous,
|
|
iterations_poisson: props.iterationsPoisson,
|
|
dt: props.dt,
|
|
BFECC: props.BFECC,
|
|
resolution: props.resolution,
|
|
isBounce: props.isBounce
|
|
});
|
|
if (webgl.autoDriver) {
|
|
webgl.autoDriver.enabled = props.autoDemo;
|
|
webgl.autoDriver.speed = props.autoSpeed;
|
|
webgl.autoDriver.resumeDelay = props.autoResumeDelay;
|
|
webgl.autoDriver.rampDurationMs = props.autoRampDuration * 1000;
|
|
if (webgl.autoDriver.mouse) {
|
|
webgl.autoDriver.mouse.autoIntensity = props.autoIntensity;
|
|
webgl.autoDriver.mouse.takeoverDuration = props.takeoverDuration;
|
|
}
|
|
}
|
|
if (props.resolution !== prevRes) sim.resize();
|
|
}
|
|
);
|
|
|
|
onMounted(() => {
|
|
initWebGL();
|
|
});
|
|
|
|
onUnmounted(() => {
|
|
if (rafRef.value) cancelAnimationFrame(rafRef.value);
|
|
if (resizeObserverRef.value) {
|
|
try {
|
|
resizeObserverRef.value.disconnect();
|
|
} catch {
|
|
/* noop */
|
|
}
|
|
}
|
|
if (intersectionObserverRef.value) {
|
|
try {
|
|
intersectionObserverRef.value.disconnect();
|
|
} catch {
|
|
/* noop */
|
|
}
|
|
}
|
|
if (webglRef.value) {
|
|
webglRef.value.dispose();
|
|
}
|
|
webglRef.value = null;
|
|
});
|
|
</script>
|