加载特效…
查看源码 / Source点击展开
<!--
ParticleSphereOGL.vue — 同款粒子球,使用 OGL(3kb 极简 WebGL 库)替代 three.js。
对比要点:
- OGL 没有后处理管线(EffectComposer/Bloom 都没有),所见即原始 shader 输出
- 没有 BufferAttribute 包装,attribute 直接 { size, data: Float32Array }
- 没有 AdditiveBlending 枚举,用 program.setBlendFunc(gl.ONE, gl.ONE)
- 没有自动 resize / dispose,要手动 setSize + loseContext
迁移说明(Vite→Nuxt):
- window.devicePixelRatio / performance.now 加 SSG 守卫(组件虽由 <ClientOnly> 包裹, 仍防御性处理)
- 本组件须在父级用 <ClientOnly> 包裹, 不在服务端渲染。
-->
<script setup lang="ts">
import { onMounted, onBeforeUnmount, useTemplateRef } from 'vue'
import { Renderer, Camera, Geometry, Program, Mesh, Vec3, Color } from 'ogl'
interface Props {
count?: number
radius?: number
pointSize?: number
colorA?: string
colorB?: string
}
const { t: tr } = useI18n({ useScope: 'local' })
const props = withDefaults(defineProps<Props>(), {
count: 18000,
radius: 1.6,
pointSize: 2.0,
colorA: '#E0F2FE',
colorB: '#38BDF8',
})
const containerRef = useTemplateRef<HTMLDivElement>('container')
let renderer: Renderer
let camera: Camera
let mesh: Mesh
let program: Program
let rafId = 0
let resizeObserver: ResizeObserver | null = null
const mouseTarget = new Vec3(0, 0, 1)
let mouseStrTarget = 0
const startTime = typeof performance !== 'undefined' ? performance.now() : 0
const vertex = /* glsl */`
attribute vec3 position;
attribute float aSeed;
uniform mat4 modelViewMatrix;
uniform mat4 projectionMatrix;
uniform float uTime;
uniform vec3 uMouse;
uniform float uMouseStr;
uniform float uPointSize;
uniform float uPixelRatio;
varying float vDisp;
float hash(vec3 p) { return fract(sin(dot(p, vec3(12.9898,78.233,37.719))) * 43758.5453); }
float noise(vec3 p){
vec3 i = floor(p), f = fract(p);
f = f*f*(3.0-2.0*f);
return mix(mix(mix(hash(i), hash(i+vec3(1,0,0)), f.x),
mix(hash(i+vec3(0,1,0)), hash(i+vec3(1,1,0)), f.x), f.y),
mix(mix(hash(i+vec3(0,0,1)), hash(i+vec3(1,0,1)), f.x),
mix(hash(i+vec3(0,1,1)), hash(i+vec3(1,1,1)), f.x), f.y), f.z);
}
void main() {
vec3 pos = position;
vec3 dir = normalize(pos);
float breathe = sin(uTime * 0.6 + aSeed * 6.28) * 0.04;
float n = noise(dir * 2.5 + uTime * 0.25);
float disp = (n - 0.5) * 0.35;
float md = distance(dir, normalize(uMouse + vec3(0.0001)));
float ripple = exp(-md * 4.0) * sin(md * 12.0 - uTime * 4.0) * uMouseStr * 0.5;
float total = breathe + disp + ripple;
pos += dir * total;
vDisp = total;
vec4 mv = modelViewMatrix * vec4(pos, 1.0);
gl_Position = projectionMatrix * mv;
float atten = 5.0 / -mv.z;
gl_PointSize = uPointSize * uPixelRatio * (0.8 + total * 1.0) * atten;
}
`
const fragment = /* glsl */`
precision highp float;
uniform vec3 uColorA;
uniform vec3 uColorB;
varying float vDisp;
void main() {
// 裸方块粒子:不裁圆、不衰减 — 体现 OGL 是底层裸 shader,不做后处理
vec2 uv = gl_PointCoord - 0.5;
// 给一个非常硬的边缘,只裁掉极少数(看起来是方块)
if (max(abs(uv.x), abs(uv.y)) > 0.5) discard;
float t = smoothstep(-0.2, 0.3, vDisp);
vec3 col = mix(uColorA, uColorB, t);
// 边缘比中心略亮一点,给"框感"
float edge = step(0.42, max(abs(uv.x), abs(uv.y)));
col += edge * 0.5;
gl_FragColor = vec4(col, 1.0);
}
`
function buildAttributes() {
const positions = new Float32Array(props.count * 3)
const seeds = new Float32Array(props.count)
const phi = Math.PI * (Math.sqrt(5) - 1)
for (let i = 0; i < props.count; i++) {
const y = 1 - (i / (props.count - 1)) * 2
const r = Math.sqrt(1 - y * y)
const theta = phi * i
positions[i * 3] = Math.cos(theta) * r * props.radius
positions[i * 3 + 1] = y * props.radius
positions[i * 3 + 2] = Math.sin(theta) * r * props.radius
seeds[i] = Math.random()
}
return { positions, seeds }
}
function onPointerMove(e: PointerEvent) {
const el = containerRef.value!
const rect = el.getBoundingClientRect()
const x = ((e.clientX - rect.left) / rect.width) * 2 - 1
const y = -((e.clientY - rect.top) / rect.height) * 2 + 1
mouseTarget.set(x, y, Math.sqrt(Math.max(0, 1 - x * x - y * y)))
mouseStrTarget = 1
}
function onPointerLeave() {
mouseStrTarget = 0
}
onMounted(() => {
const el = containerRef.value!
const w = el.clientWidth
const h = el.clientHeight
const dpr = Math.min(window.devicePixelRatio, 2)
renderer = new Renderer({ dpr, alpha: false, antialias: true })
const gl = renderer.gl
gl.clearColor(0.02, 0.025, 0.04, 1.0) // 极简深蓝灰 #050610
el.appendChild(gl.canvas)
renderer.setSize(w, h)
camera = new Camera(gl, { fov: 55, near: 0.1, far: 100, aspect: w / h })
camera.position.set(0, 0, 5)
const { positions, seeds } = buildAttributes()
const geometry = new Geometry(gl, {
position: { size: 3, data: positions },
aSeed: { size: 1, data: seeds },
})
const cA = new Color(props.colorA)
const cB = new Color(props.colorB)
program = new Program(gl, {
vertex,
fragment,
uniforms: {
uTime: { value: 0 },
uMouse: { value: new Vec3(0, 0, 1) },
uMouseStr: { value: 0 },
uPointSize: { value: props.pointSize },
uPixelRatio: { value: dpr },
uColorA: { value: new Vec3(cA.r, cA.g, cA.b) },
uColorB: { value: new Vec3(cB.r, cB.g, cB.b) },
},
transparent: true,
depthWrite: false,
})
// OGL 极简版:用普通 alpha 混合而非加性,保留冷峻线框感
program.setBlendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA)
mesh = new Mesh(gl, { geometry, program, mode: gl.POINTS })
const tick = () => {
const t = (performance.now() - startTime) / 1000
program.uniforms.uTime.value = t
// OGL 的 Vec3.lerp(target, alpha)
program.uniforms.uMouse.value.lerp(mouseTarget, 0.1)
program.uniforms.uMouseStr.value += (mouseStrTarget - program.uniforms.uMouseStr.value) * 0.08
mesh.rotation.y = t * 0.08
mesh.rotation.x = Math.sin(t * 0.15) * 0.2
renderer.render({ scene: mesh, camera })
rafId = requestAnimationFrame(tick)
}
tick()
resizeObserver = new ResizeObserver(entries => {
for (const entry of entries) {
const cr = entry.contentRect
renderer.setSize(cr.width, cr.height)
camera.perspective({ aspect: cr.width / cr.height })
}
})
resizeObserver.observe(el)
})
onBeforeUnmount(() => {
cancelAnimationFrame(rafId)
resizeObserver?.disconnect()
if (renderer) {
const gl = renderer.gl
const ext = gl.getExtension('WEBGL_lose_context')
ext?.loseContext()
if (gl.canvas.parentNode) gl.canvas.parentNode.removeChild(gl.canvas)
}
})
</script>
<template>
<div ref="container" class="ps-wrap" @pointermove="onPointerMove" @pointerleave="onPointerLeave">
<div class="ps-hint">{{ tr('hint') }}</div>
</div>
</template>
<i18n lang="json">
{
"zh": { "hint": "ogl · 移动鼠标" },
"en": { "hint": "ogl · drag to interact" }
}
</i18n>
<style scoped>
.ps-wrap {
position: relative;
width: 100%;
height: 100%;
min-height: 480px;
background: radial-gradient(ellipse at center, #0a0c14 0%, #050610 70%);
overflow: hidden;
border-radius: 12px;
cursor: crosshair;
}
/* OGL 自己 append canvas, 没有 TresCanvas 的内置铺满规则, 必须强制铺满容器,
否则 canvas 用 setSize 时拿到的 clientHeight 不对(grid 单元 auto 高 → 拉伸成竖条) */
.ps-wrap :deep(canvas) {
display: block;
width: 100% !important;
height: 100% !important;
}
.ps-hint {
position: absolute;
left: 16px;
bottom: 14px;
z-index: 10;
font-family: 'Consolas', monospace;
font-size: 11px;
letter-spacing: 3px;
color: rgba(255, 255, 255, 0.45);
pointer-events: none;
user-select: none;
}
</style>