主要改动: fix(docker/dify-deploy): 修复脚本核心功能 - heredoc 单引号 bug: 'ENVEOF' 改为 ENVEOF,变量正确展开 - 端口默认值 8083/8084/8085 对齐 .env.prod 生产配置 - 加 dc_cmd() 兼容 docker-compose v1/v2 plugin - openssl rand 生成强随机密码与 SECRET_KEY(42 字符) - install 跳过已存在 .env,保护用户配置(管理员密码/SECRET_KEY) - read -p < /dev/tty 兼容非 tty 环境(CI/CD) - show-config 改用 DIFY_NGINX_PORT(nginx 入口)而非 APP_WEB_PORT docs(mvp-design): 修正 §3.2 workflow inputs 描述 - 实际只有 query,删除错误的 user_id input 声明 - 节点序列图同步更新 feat(aiChatService): 新增 Dify 客户端与适配器 - service/dify_client.go: Dify Workflow 调用 + SSE 解析 - service/dify_adapter.go: 与现有 chat_service 桥接 - provider/ai_chat_provider.go: Dubbo 入口简化 - main.go: 装配 ConversationRepository + DifyClient feat(migrations): 新增 AI 搭子会话表 ai_chat.sql - ai_conversations / ai_messages 表 + 索引 docs: 新增 Dify 集成设计文档 - 2026-06-29-ai-chat-dify-mvp-design.md (MVP 实施级) - 2026-06-29-ai-chat-dify-integration-v2-design.md (V2 演进路线图) - docs/dify/角角.yml (Workflow DSL 导出) config: 更新 env 模板与 docker 配置 - backend/.env.example: DIFY_* 环境变量声明 - docker/.env.prod: DIFY_API_BASE 对齐 8083 - docker/build.sh: 微调 - CLAUDE.md: 项目规范补充 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
21 KiB
光栅卡 WebGL 引擎实施计划
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: 用 WebGL 实现光栅卡的条纹级像素交织,替代当前 CSS DOM opacity 叠化方案
Architecture: 新增 LenticularWebGLEngine(WebGL 引擎类)和片段着色器(条纹交织 + 圆角暗角),保留 LenticularEngine 的现有权重算法并在 computeRenderState() 末尾汇出 engineUniforms,useLenticularPreview 增加 registerOnTick 回调实现唯一 rAF 驱动,LenticularCard.vue 改为 WebGL / CSS DOM 双路径渲染
Tech Stack: WebGL1 (ES 100), uniapp APP-PLUS, Vue 3 composable
Global Constraints
- 仅适配 APP-PLUS,无 H5/小程序降级
- 2 层图(背景 + 主体),不支持 3+
- 无全息效果、无噪声、无色散、无安全区域
LenticularEngine原有输出renderState+layerTransforms不破坏- 文件组织与
utils/laser-card/对仗,放utils/lenticular-card/ - WebGL 不可用时自动降级到现有 CSS DOM 路径
- 陀螺仪代码
useLenticularStudioTilt.js不修改
文件结构
新增
| 文件 | 职责 |
|---|---|
frontend/utils/lenticular-card/lenticular-webgl-shaders.js |
VERT_SRC + FRAG_SRC(~80 行 GLSL) |
frontend/utils/lenticular-card/lenticular-webgl-engine.js |
LenticularWebGLEngine 类(~200 行) |
修改
| 文件 | 改动 |
|---|---|
frontend/utils/lenticular-engine.js → 移入 utils/lenticular-card/ |
文件迁移 + computeRenderState() 末尾汇出 engineUniforms |
frontend/composables/useLenticularPreview.js |
追加 engineUniforms 返回值 + registerOnTick |
frontend/components/lenticular/LenticularCard.vue |
双路径渲染 + 引擎生命周期 |
不改
useLenticularStudioTilt.js、HolographicCard.vue、HolographicEngine、全部页面层(lenticular-create/result/thinking.vue)
Task 1: 建立 utils/lenticular-card/ 目录并迁移 lenticular-engine.js
Files:
- Create:
frontend/utils/lenticular-card/ - Move:
frontend/utils/lenticular-engine.js→frontend/utils/lenticular-card/lenticular-engine.js
Interfaces:
-
Consumes: 无(纯文件迁移)
-
Produces:
lenticular-engine.js在utils/lenticular-card/目录下可被import引用 -
Step 1: 创建目录
mkdir -p frontend/utils/lenticular-card
- Step 2: 复制文件到新位置
cp frontend/utils/lenticular-engine.js frontend/utils/lenticular-card/lenticular-engine.js
- Step 3: 确认所有引用旧路径的地方
搜索 @/utils/lenticular-engine.js 找到所有引用:
grep -rn "lenticular-engine" frontend/ --include="*.js" --include="*.vue"
预期至少命中:
frontend/composables/useLenticularPreview.jsfrontend/components/lenticular/LenticularCard.vue- 可能的测试文件
记录所有路径,后续 task 逐个更新。
- Step 4: 删除旧文件
rm frontend/utils/lenticular-engine.js
Task 2: 编写 lenticular-webgl-shaders.js
Files:
- Create:
frontend/utils/lenticular-card/lenticular-webgl-shaders.js
Interfaces:
-
Consumes: 无
-
Produces:
export const VERT_SRC和export const FRAG_SRC,供LenticularWebGLEngine使用 -
Step 1: 实现 VERT_SRC + FRAG_SRC
/**
* 光栅卡 WebGL 着色器
* 管线:UV 偏移 → 条纹交织 → 输出修饰
* 无全息效果
*/
export const VERT_SRC = `
attribute vec2 a_position;
attribute vec2 a_texCoord;
varying vec2 v_texCoord;
void main() {
v_texCoord = a_texCoord;
gl_Position = vec4(a_position * 2.0 - 1.0, 0.0, 1.0);
}
`
export const FRAG_SRC = `
precision highp float;
varying vec2 v_texCoord;
uniform sampler2D u_textureA;
uniform sampler2D u_textureB;
uniform float u_parallax;
uniform float u_phase;
uniform float u_density;
uniform float u_cornerRadius;
uniform vec2 u_resolution;
uniform float u_dpr;
// ---- 圆角 SDF ----
float roundedRectSDF(vec2 p, vec2 halfSize, float r) {
vec2 q = abs(p) - halfSize + r;
return min(max(q.x, q.y), 0.0) + length(max(q, 0.0)) - r;
}
void main() {
// ① UV 偏移
vec2 uv = v_texCoord;
uv.x += u_parallax * 0.03;
float scale = 1.0 + abs(u_parallax) * 0.015;
uv = (uv - 0.5) * scale + 0.5;
// ② 条纹交织 —— 每个像素只来自一张图
float stripe = fract(uv.x * u_density + u_phase);
float mask = smoothstep(0.45, 0.55, stripe);
vec4 baseColor = texture2D(u_textureA, uv);
vec4 frontColor = texture2D(u_textureB, uv);
vec4 finalColor = mix(baseColor, frontColor, mask);
// ③ 输出修饰
vec2 halfRes = u_resolution * 0.5;
vec2 pn = (uv - 0.5) * u_resolution;
float cornerRadPx = u_cornerRadius * u_dpr;
float sdf = roundedRectSDF(pn, halfRes - cornerRadPx, cornerRadPx);
if (sdf > 1.5) discard;
// 暗角
vec2 pnNorm = pn / max(halfRes.x, halfRes.y);
float vignette = 1.0 - pow(clamp(length(pnNorm) * 1.1, 0.0, 1.0), 2.8) * 0.35;
finalColor.rgb *= vignette;
// 边缘抗锯齿
float cornerMask = 1.0 - smoothstep(-1.5, 1.5, sdf);
float edgeAA = 1.0 - smoothstep(-1.5, 1.5, sdf);
float alpha = cornerMask * edgeAA;
finalColor = clamp(finalColor, 0.0, 1.0);
gl_FragColor = vec4(finalColor.rgb, alpha);
}
`
Task 3: 编写 lenticular-webgl-engine.js
Files:
- Create:
frontend/utils/lenticular-card/lenticular-webgl-engine.js
Interfaces:
-
Consumes:
VERT_SRC,FRAG_SRCfrom./lenticular-webgl-shaders.js;loadTextureImagefrom@/utils/laser-card/laserPreviewWebgl.js -
Produces:
class LenticularWebGLEngine暴露init(textureA, textureB),resize(w, h),setUniforms({parallax, phase, density}),draw(),uploadTexture(index, image),destroy() -
Step 1: 实现 LenticularWebGLEngine 类
/**
* 光栅卡 WebGL 渲染引擎
* 单 DrawCall,8 uniforms,3 阶段管线
*/
import { loadTextureImage } from '@/utils/laser-card/laserPreviewWebgl.js'
import { VERT_SRC, FRAG_SRC } from './lenticular-webgl-shaders.js'
export class LenticularWebGLEngine {
constructor(canvas) {
this.canvas = canvas
this.gl = null
this.program = null
this.uniformLocs = {}
this.textures = [null, null]
this._dpr = 1
this._uniforms = { parallax: 0, phase: 0, density: 0.16 }
this._initialized = false
this._destroyed = false
}
init(textureSrcA, textureSrcB) {
if (this._initialized || this._destroyed) return false
const gl = this.canvas.getContext('webgl', {
alpha: true, antialias: true, premultipliedAlpha: false,
powerPreference: 'high-performance',
})
if (!gl) return false
this.gl = gl
this._dpr = Math.min(window.devicePixelRatio || 1, 2)
this._syncSize()
// 编译 shader
const vs = this._compileShader(gl.VERTEX_SHADER, VERT_SRC)
const fs = this._compileShader(gl.FRAGMENT_SHADER, FRAG_SRC)
if (!vs || !fs) return false
this.program = gl.createProgram()
gl.attachShader(this.program, vs)
gl.attachShader(this.program, fs)
gl.linkProgram(this.program)
if (!gl.getProgramParameter(this.program, gl.LINK_STATUS)) return false
// 缓存 uniform 位置
const names = ['u_textureA', 'u_textureB', 'u_parallax', 'u_phase', 'u_density',
'u_cornerRadius', 'u_resolution', 'u_dpr']
for (const name of names) {
this.uniformLocs[name] = gl.getUniformLocation(this.program, name)
}
// 全屏四边形
const verts = new Float32Array([0,0, 1,0, 0,1, 1,0, 0,1, 1,1])
const buf = gl.createBuffer()
gl.bindBuffer(gl.ARRAY_BUFFER, buf)
gl.bufferData(gl.ARRAY_BUFFER, verts, gl.STATIC_DRAW)
const posLoc = gl.getAttribLocation(this.program, 'a_position')
const tcLoc = gl.getAttribLocation(this.program, 'a_texCoord')
gl.enableVertexAttribArray(posLoc)
gl.enableVertexAttribArray(tcLoc)
gl.vertexAttribPointer(posLoc, 2, gl.FLOAT, false, 16, 0)
gl.vertexAttribPointer(tcLoc, 2, gl.FLOAT, false, 16, 8)
// 纹理
this.textures[0] = gl.createTexture()
this.textures[1] = gl.createTexture()
gl.useProgram(this.program)
gl.uniform1i(this.uniformLocs.u_textureA, 0)
gl.uniform1i(this.uniformLocs.u_textureB, 1)
// 加载纹理
if (textureSrcA) this._loadTexture(0, textureSrcA)
if (textureSrcB) this._loadTexture(1, textureSrcB)
this._initialized = true
return true
}
resize(cssW, cssH) {
const w = Math.round(cssW * this._dpr)
const h = Math.round(cssH * this._dpr)
if (this.canvas.width === w && this.canvas.height === h) return
this.canvas.width = w
this.canvas.height = h
this.gl && this.gl.viewport(0, 0, w, h)
}
_syncSize() {
const rect = this.canvas.getBoundingClientRect()
if (rect.width && rect.height) this.resize(rect.width, rect.height)
}
setUniforms(u) {
Object.assign(this._uniforms, u)
}
draw() {
const gl = this.gl
if (!gl || !this._initialized) return
gl.useProgram(this.program)
gl.uniform1f(this.uniformLocs.u_parallax, this._uniforms.parallax)
gl.uniform1f(this.uniformLocs.u_phase, this._uniforms.phase)
gl.uniform1f(this.uniformLocs.u_density, this._uniforms.density)
gl.uniform1f(this.uniformLocs.u_cornerRadius, this._uniforms.cornerRadius || 24)
gl.uniform2f(this.uniformLocs.u_resolution, this.canvas.width, this.canvas.height)
gl.uniform1f(this.uniformLocs.u_dpr, this._dpr)
gl.drawArrays(gl.TRIANGLES, 0, 6)
}
uploadTexture(index, image) {
if (index < 0 || index > 1 || !this.gl) return
this._loadTexture(index, image)
}
async _loadTexture(index, src) {
const gl = this.gl
const img = typeof src === 'string'
? await loadTextureImage(src)
: src
if (!img) return
gl.activeTexture(gl.TEXTURE0 + index)
gl.bindTexture(gl.TEXTURE_2D, this.textures[index])
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, img)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)
gl.generateMipmap(gl.TEXTURE_2D)
}
_compileShader(type, src) {
const gl = this.gl
const s = gl.createShader(type)
gl.shaderSource(s, src)
gl.compileShader(s)
if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) {
console.error('[LenticularWebGLEngine] shader compile error:', gl.getShaderInfoLog(s))
return null
}
return s
}
destroy() {
const gl = this.gl
if (gl) {
if (this.program) gl.deleteProgram(this.program)
for (const t of this.textures) { if (t) gl.deleteTexture(t) }
}
this._destroyed = true
this._initialized = false
}
}
Task 4: lenticular-engine.js 追加 engineUniforms 汇出
Files:
- Modify:
frontend/utils/lenticular-card/lenticular-engine.js—computeRenderState()末尾
Interfaces:
-
Consumes: 已有
this.renderState、this.physics、this.displayGamma -
Produces:
computeRenderState()返回值新增字段engineUniforms: { parallax, phase, density } -
Step 1: computeRenderState() 末尾追加汇出代码
找到 lenticular-engine.js 中 computeRenderState() 的 return this.renderState 行,在其之前插入 engineUniforms 计算。注意 this.renderState.stripePhaseShift 已经由原有逻辑写入。
// lenticular-engine.js — computeRenderState() 末尾,在 return 之前追加
// ===== WebGL engineUniforms 汇出(不破坏原有输出)=====
const parallax = this.displayGamma * (this.physics.tiltSensitivity / 100) * (this.physics.parallaxDepth || 0) * 0.42
const sensitivity = this.physics.tiltSensitivity / 100
const stripePhaseShift = this.renderState.stripePhaseShift != null
? this.renderState.stripePhaseShift
: this.displayGamma * (0.38 + 0.52 * sensitivity)
const phase = (stripePhaseShift + 1) / 2 // -1~1 → 0~1
const pitchPx = this.renderState.lenticularPitchPx || 16
const density = 50 / pitchPx
this.renderState.engineUniforms = {
parallax: clamp(parallax, -1, 1),
phase: clamp(phase, 0, 1),
density: clamp(density, 0.08, 0.3),
}
return this.renderState
注意:函数顶部已有 clamp,无需重复定义。
Task 5: useLenticularPreview.js 追加 engineUniforms + registerOnTick
Files:
- Modify:
frontend/composables/useLenticularPreview.js - Modify: 更新 Task 1 中旧路径引用为
@/utils/lenticular-card/lenticular-engine
Interfaces:
-
Consumes:
LenticularEngine的computeRenderState()返回的engineUniforms -
Produces: 新增返回值
engineUniforms(ref)、registerOnTick(fn)函数 -
Step 1: 更新 import 路径
// 旧
import { LenticularEngine, DEFAULT_PHYSICS } from '@/utils/lenticular-engine.js'
// 新
import { LenticularEngine, DEFAULT_PHYSICS } from '@/utils/lenticular-card/lenticular-engine.js'
- Step 2: 新增 registerOnTick 机制
在 useLenticularPreview 函数体内,let rafId = null 之后新增:
let onTickCallback = null
export function registerOnTick(fn) {
onTickCallback = typeof fn === 'function' ? fn : null
}
- Step 3: tick() 中调用 onTickCallback
将 tick() 函数从:
function tick() {
try {
const ls = getLayersArray()
const renderState = engine.feedSimulatedTilt(sensorData.value.gamma, sensorData.value.beta)
applyLayerTransformsFromRenderState(ls, renderState)
} catch (e) {
console.error('[useLenticularPreview] tick failed', e)
}
rafId = nextFrame(tick)
}
改为:
function tick() {
try {
const ls = getLayersArray()
const renderState = engine.feedSimulatedTilt(sensorData.value.gamma, sensorData.value.beta)
applyLayerTransformsFromRenderState(ls, renderState)
// WebGL onTick
if (onTickCallback && renderState.engineUniforms) {
onTickCallback(renderState.engineUniforms)
}
} catch (e) {
console.error('[useLenticularPreview] tick failed', e)
}
rafId = nextFrame(tick)
}
- Step 4: 追加到返回值
// 现有 return 末尾追加
return {
// ... 原有返回值
registerOnTick,
}
Task 6: 重构 LenticularCard.vue — 双路径渲染
Files:
- Modify:
frontend/components/lenticular/LenticularCard.vue - Modify: 更新 import 路径为
@/utils/lenticular-card/lenticular-engine
Interfaces:
-
Consumes:
useLenticularPreview返回的layerTransforms+registerOnTick -
Consumes:
LenticularWebGLEnginefrom@/utils/lentricular-card/lenticular-webgl-engine -
Consumes:
loadTextureImagefrom@/utils/laser-card/laserPreviewWebgl.js -
Step 1: 更新 script setup 头部
<script setup>
import { computed, getCurrentInstance, onMounted, ref, watch, nextTick } from 'vue'
import { useLenticularPreview } from '@/composables/useLenticularPreview.js'
import { LenticularWebGLEngine } from '@/utils/lenticular-card/lenticular-webgl-engine.js'
import { loadTextureImage } from '@/utils/laser-card/laserPreviewWebgl.js'
const props = defineProps({
layers: { type: Array, required: true },
transforms: { type: Object, default: () => ({}) },
gyroSource: { type: String, default: 'simulation' },
tiltHintText: { type: String, default: '倾斜手机预览' },
approximatePreview: { type: Boolean, default: true },
skipBuiltInTouch: { type: Boolean, default: false },
// WebGL 专属
cornerRadius: { type: Number, default: 24 },
webglPreferred: { type: Boolean, default: true },
shimmerMidOpacity: { type: Number, default: 0.1 },
})
const emit = defineEmits(['simulate', 'ready', 'error'])
- Step 2: 新增 WebGL 状态 + 引擎生命周期
const pageProxy = getCurrentInstance()?.proxy
const showHint = ref(true)
const cardId = `lcard-${Math.random().toString(36).slice(2, 9)}`
const cardRect = ref(null)
// ---- WebGL 状态 ----
const webglCanvas = ref(null)
const useWebgl = ref(false)
let webglEngine = null
let resizeObserver = null
- Step 3: 接入 composable + 注册 onTick
const layersRef = computed(() => props.layers)
const { physics, layerTransforms, stripeRender, gyro, simulate, relax, snapSimulatedTilt,
startRenderLoop, stopRenderLoop, registerOnTick } = useLenticularPreview(layersRef)
- Step 4: 初始化 WebGL(onMounted 中)
async function initWebGL() {
if (!props.webglPreferred) return false
const canvas = webglCanvas.value
if (!canvas) return false
const engine = new LenticularWebGLEngine(canvas)
// 获取两张图的 src
const srcA = props.layers[0]?.src || ''
const srcB = props.layers[1]?.src || ''
const ok = engine.init(srcA, srcB)
if (!ok) {
engine.destroy()
return false
}
webglEngine = engine
// 监听尺寸变化
if (typeof ResizeObserver !== 'undefined') {
resizeObserver = new ResizeObserver((entries) => {
for (const e of entries) {
const { width, height } = e.contentRect
if (engine && width > 0 && height > 0) engine.resize(width, height)
}
})
ro.observe(canvas.parentElement || canvas)
}
// 注册 onTick
registerOnTick((uniforms) => {
if (webglEngine) {
webglEngine.setUniforms({
...uniforms,
cornerRadius: props.cornerRadius,
})
webglEngine.draw()
}
})
useWebgl.value = true
emit('ready')
return true
}
- Step 5: onMounted 决策 WebGL / CSS
onMounted(async () => {
const webglOk = await initWebGL()
if (!webglOk) {
// 降级到 CSS DOM
setTimeout(() => { void refreshRect() }, 0)
emit('error', new Error('WebGL init failed, fallback to CSS'))
}
startRenderLoop()
})
onUnmounted(() => {
stopRenderLoop()
if (resizeObserver) { resizeObserver.disconnect(); resizeObserver = null }
if (webglEngine) {
webglEngine.destroy()
webglEngine = null
}
})
- Step 6: 更新 template 为双路径
<template>
<view class="card-container">
<!-- WebGL 路径 -->
<canvas
v-if="useWebgl"
ref="webglCanvas"
class="card-canvas"
:style="{ width: '100%', height: '100%' }"
@touchstart.stop="onFrameTouchStart"
@touchmove.stop.prevent="onFrameTouchMove"
@touchend.stop="onFrameTouchEnd"
@touchcancel.stop="onFrameTouchEnd"
/>
<!-- CSS 降级路径 -->
<template v-else>
<view
:id="cardId"
class="card-frame"
@touchstart.stop="onFrameTouchStart"
@touchmove.stop.prevent="onFrameTouchMove"
@touchend.stop="onFrameTouchEnd"
@touchcancel.stop="onFrameTouchEnd"
>
<view class="card-body" :style="cardRotateStyle">
<view
v-for="layer in layers"
:key="layer.id"
class="card-layer"
:style="getLayerStyle(layer)"
>
<image
v-if="layer.src"
class="card-layer-img"
:src="layer.src"
mode="aspectFill"
@error="onImageError"
@load="onImageLoad"
/>
</view>
<view class="lenticular-shimmer" :style="shimmerStyle" />
<view class="lenticular-tint" />
<view class="glass-rim" />
<view class="vignette" />
</view>
</view>
</template>
</view>
</template>
- Step 7: 添加 CSS
<style scoped>
.card-canvas {
display: block;
width: 100%;
height: 100%;
}
/* 保留原有 CSS DOM 路径的全部样式 */
.card-container { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; perspective: 1000px; position: relative; }
.card-frame { position: relative; height: 100%; width: 100%; overflow: visible; }
.card-body { position: absolute; inset: 0; transform-style: preserve-3d; box-shadow: 0 20px 50px rgba(0,0,0,0.55); border: 1px solid rgba(255,255,255,0.18); background-color: #060e20; will-change: transform; }
.card-layer { position: absolute; width: 100%; height: 100%; will-change: transform, opacity; background-size: cover; background-position: center; }
.card-layer-img { width: 100%; height: 100%; }
.lenticular-shimmer { position: absolute; inset: 0; pointer-events: none; opacity: 0.85; }
.lenticular-tint { position: absolute; inset: 0; pointer-events: none; background: linear-gradient(135deg, rgba(221,183,255,0.1) 0%, transparent 35%, transparent 65%, rgba(76,215,246,0.1) 100%); opacity: 0.6; }
.glass-rim { position: absolute; inset: 0; border-radius: 24px; border-top: 1px solid rgba(221,183,255,0.42); box-shadow: inset 0 0 40px rgba(255,255,255,0.05); pointer-events: none; }
.vignette { position: absolute; inset: 0; background: linear-gradient(to top, rgba(6,14,32,0.82), transparent 50%); pointer-events: none; }
</style>
验收标准
| # | 验证项 | 方法 |
|---|---|---|
| 1 | WebGL 条纹交织 | 创建页打开,两张图倾斜时条纹交替,无叠化模糊 |
| 2 | CSS DOM 降级 | 设置 webglPreferred=false,页面与改动前一致 |
| 3 | 陀螺仪驱动 | 结果页用真机倾斜,条纹随角度平滑移动,无抖动 |
| 4 | 生命周期 | 页面进出各 3 次,无 WebGL context lost 报错 |
| 5 | Android 低端机 | 测试机输出 fps > 50,倾斜响应 < 50ms |