修改:优化手机端效果图点云效果
continuous-integration/drone/push Build was killed
continuous-integration/drone/promote/prod Build is passing

This commit is contained in:
Linzm committed 2026-10-20 17:14:35 +08:00
1 parent 605bd894c5
commit 5892c47a6b
5 files changed
+132 -43

No files matched your search

+5 -1
View File
@@ -4,7 +4,11 @@ const PREFIX = 'douyin'
function normalizeOssUrl(url: string) {
if (!url) return ''
return String(url).replace(/\\u0026/gi, '&').replace(/\\u003d/gi, '=')
let s = String(url).replace(/\\u0026/gi, '&').replace(/\\u003d/gi, '=').trim()
// 真机 https 页加载 http 图会被拦截,统一升 https
if (s.indexOf('//') === 0) s = `https:${s}`
if (/^http:\/\//i.test(s)) s = `https://${s.slice(7)}`
return s
}
function guessContentType(file: File | Blob, filename?: string) {
@@ -186,9 +186,9 @@ function preloadResult(url: string) {
if (!url || !renderer || !sm) return
resultKey = url
sm.setResultPreloadStatus('loading')
// 效果图保留完整画面做点云(去背景会在手机上采空,表现为「没有效果图点云」)
// 效果图点云去掉背景,只保留主体
renderer.textures
.load(url, { removeBackground: false })
.load(url, { removeBackground: true })
.then((entry: any) => {
if (destroyed || !sm) return
if (!entry || !entry.pointCloudBuffer) {
@@ -428,14 +428,16 @@ onUnmounted(() => {
opacity: 0;
}
/* 仅占位;真实采样已改用离屏 canvas,勿再用 CSS 锁尺寸 */
.ipc-scale {
position: absolute !important;
width: 1px !important;
height: 1px !important;
left: -9999px !important;
top: 0 !important;
width: 2px;
height: 2px;
opacity: 0;
pointer-events: none;
left: -9999px !important;
top: auto !important;
visibility: hidden;
}
.ipc-fallback {
@@ -46,11 +46,11 @@ function createPointCloudSampler(scaleCanvas, viewportW, viewportH, sharedCtx) {
const forceSampleSize = options.forceSampleSize || null
const image = entry.image
if (!image || !image.width || !image.height) {
const srcW = (image && (image.naturalWidth || image.width)) || 0
const srcH = (image && (image.naturalHeight || image.height)) || 0
if (!image || !srcW || !srcH) {
throw new Error('image not ready for sampling')
}
const srcW = image.width
const srcH = image.height
let w
let h
if (forceSampleSize && forceSampleSize.width && forceSampleSize.height) {
@@ -152,13 +152,20 @@ function createPointCloudSampler(scaleCanvas, viewportW, viewportH, sharedCtx) {
const dg = g - bgG
const db = b - bgB
const distance = Math.sqrt(dr * dr + dg * dg + db * db) / 441.67
const notBackground = Math.max(0, Math.min(1, (distance - 0.025) / 0.09))
const softKnockout = !!options.softKnockout
// 默认更狠地抠掉接近四角背景色的点;softKnockout 略放宽防采空
const distCut = softKnockout ? 0.028 : 0.05
const distRamp = softKnockout ? 0.08 : 0.07
if (distance < distCut) continue
const notBackground = Math.max(0, Math.min(1, (distance - distCut) / distRamp))
const chroma = Math.abs(r - g) + Math.abs(g - b) + Math.abs(r - b)
const nearBlack = lum < 0.07 && chroma < 16
if (nearBlack) continue
const darkKeep = Math.max(0, Math.min(1, (0.62 - lum) / 0.45))
const softKeep = Math.max(0, Math.min(1, (distance - 0.015) / 0.07))
presence = Math.max(notBackground, darkKeep * 0.5, softKeep * 0.4) * a
const bgLum = luminance(bgR, bgG, bgB)
// 仅亮底时丢弃近黑噪点,避免黑底效果图误杀主体暗部
if (bgLum > 0.35) {
const nearBlack = lum < 0.07 && chroma < 16
if (nearBlack) continue
}
presence = notBackground * a
subjectWeight = 1
}
@@ -235,11 +235,41 @@ function createRenderer(canvas, cssW, cssH, dpr, scaleCanvas) {
gl.clear(gl.COLOR_BUFFER_BIT)
const pointOpacity = u.opacity != null ? u.opacity : 1
const reveal = u.reveal != null ? u.reveal : 0
// 完整效果图改由页面层按预览框位展示,避免 WebGL 全屏图与预览错位
if (pointOpacity > 0.001 && cloudA && cloudB) {
gl.enable(gl.BLEND)
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA)
gl.enable(gl.BLEND)
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA)
// 揭示阶段绘制效果图纹理,避免点云淡出后整页黑屏
if (hasResult && reveal > 0.001 && resultTex) {
const fit = containFit(contentAspect || 1, cssW, cssH, 0.98)
gl.useProgram(quadProgram)
gl.activeTexture(gl.TEXTURE0)
gl.bindTexture(gl.TEXTURE_2D, resultTex)
gl.uniform1i(quadUni.uTexture, 0)
gl.uniform1f(quadUni.uOpacity, Math.min(1, reveal))
gl.uniform2f(quadUni.uContentScale, fit.scaleX, fit.scaleY)
gl.uniform2f(quadUni.uContentOffset, fit.offsetX, fit.offsetY)
gl.uniform4f(
quadUni.uBgColor,
VISUAL.STAGE_BG[0],
VISUAL.STAGE_BG[1],
VISUAL.STAGE_BG[2],
1
)
gl.bindBuffer(gl.ARRAY_BUFFER, quadBuffer)
if (quadAttr.aPosition >= 0) {
gl.enableVertexAttribArray(quadAttr.aPosition)
gl.vertexAttribPointer(quadAttr.aPosition, 2, gl.FLOAT, false, 16, 0)
}
if (quadAttr.aUv >= 0) {
gl.enableVertexAttribArray(quadAttr.aUv)
gl.vertexAttribPointer(quadAttr.aUv, 2, gl.FLOAT, false, 16, 8)
}
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4)
}
if (pointOpacity > 0.001 && cloudA && cloudB && cloudA.buffer && cloudB.buffer) {
gl.useProgram(pointProgram)
bindCloudAttribs(
@@ -14,23 +14,27 @@ function createTextureManager(gl, canvas, scaleCanvas, viewportW, viewportH) {
let maxTexSize = VISUAL.MAX_TEX_SIZE
let maxPoints = POINT_CLOUD.MEDIUM
let sampleSide = POINT_CLOUD.SAMPLE_MEDIUM
let scaleCtx = null
if (scaleCanvas) {
/**
* 真机关键:不要用 DOM 里被 CSS 锁成 1×1 的 canvas 做 getImageData。
* 部分 WebView 会把绘图缓冲钳到 CSS 尺寸,导致效果图采点为空 → 整页黑屏。
*/
const sampleCanvas = document.createElement('canvas')
let scaleCtx = null
try {
scaleCtx = sampleCanvas.getContext('2d', { willReadFrequently: true })
} catch (e) {
try {
// getImageData 频繁读回:声明 willReadFrequently 消除告警并提升性能
scaleCtx = scaleCanvas.getContext('2d', { willReadFrequently: true })
} catch (e) {
try {
scaleCtx = scaleCanvas.getContext('2d')
} catch (e2) {
scaleCtx = null
}
scaleCtx = sampleCanvas.getContext('2d')
} catch (e2) {
scaleCtx = null
}
}
// 保留入参 scaleCanvas 作兼容,实际采样/缩放一律走离屏 canvas
void scaleCanvas
const sampler = createPointCloudSampler(
scaleCanvas,
sampleCanvas,
viewportW,
viewportH,
scaleCtx
@@ -114,11 +118,20 @@ function createTextureManager(gl, canvas, scaleCanvas, viewportW, viewportH) {
}
}
function normalizeImageSrc(src) {
let s = String(src || '').trim()
if (!s) return ''
if (s.indexOf('//') === 0) s = 'https:' + s
if (/^http:\/\//i.test(s)) s = 'https://' + s.slice(7)
return s
}
/**
* 加载图片并严格在 onload 之后再交给后续逻辑。
* http(s) 优先 fetch→blob,避免手机 WebView 跨域导致 getImageData 采点失败。
*/
function loadImage(src) {
function loadImage(rawSrc) {
const src = normalizeImageSrc(rawSrc)
return new Promise(function (resolve, reject) {
if (!src) {
reject(new Error('empty src'))
@@ -136,8 +149,8 @@ function createTextureManager(gl, canvas, scaleCanvas, viewportW, viewportH) {
if (settled) return
setTimeout(function () {
if (settled) return
const w = img.width || 0
const h = img.height || 0
const w = img.naturalWidth || img.width || 0
const h = img.naturalHeight || img.height || 0
if (!w || !h) {
settled = true
if (revokeUrl) {
@@ -173,15 +186,36 @@ function createTextureManager(gl, canvas, scaleCanvas, viewportW, viewportH) {
// 远程图:先 blob 同源化,再采样(手机端效果图点云依赖此路径)
if (/^https?:\/\//i.test(src) && typeof fetch === 'function') {
fetch(src, { mode: 'cors', credentials: 'omit', cache: 'force-cache' })
fetch(src, { mode: 'cors', credentials: 'omit', cache: 'no-cache' })
.then(function (res) {
if (!res || !res.ok) throw new Error('fetch image http ' + (res && res.status))
return res.blob()
})
.then(function (blob) {
if (!blob || !blob.size) throw new Error('empty image blob')
// createImageBitmap 在部分 WebView 上更稳,再回退 blob URL
if (typeof createImageBitmap === 'function') {
return createImageBitmap(blob).then(function (bitmap) {
if (bitmap && bitmap.width && bitmap.height) {
resolve({
image: bitmap,
path: src,
width: bitmap.width,
height: bitmap.height,
revokeUrl: '',
})
return null
}
throw new Error('empty bitmap')
}).catch(function () {
const objUrl = URL.createObjectURL(blob)
bindImage(objUrl, objUrl)
return null
})
}
const objUrl = URL.createObjectURL(blob)
bindImage(objUrl, objUrl)
return null
})
.catch(function () {
// CORS 拉流失败时退回 <img crossOrigin>
@@ -195,11 +229,11 @@ function createTextureManager(gl, canvas, scaleCanvas, viewportW, viewportH) {
}
function uploadTexture(image, path) {
if (!image || !image.width || !image.height) {
if (!image || !(image.width || image.naturalWidth) || !(image.height || image.naturalHeight)) {
throw new Error('uploadTexture: image not ready')
}
const srcW = image.width || 1
const srcH = image.height || 1
const srcW = image.naturalWidth || image.width || 1
const srcH = image.naturalHeight || image.height || 1
let drawW = srcW
let drawH = srcH
const longest = Math.max(srcW, srcH)
@@ -212,9 +246,9 @@ function createTextureManager(gl, canvas, scaleCanvas, viewportW, viewportH) {
const tex = createEmptyTexture()
gl.bindTexture(gl.TEXTURE_2D, tex)
try {
if ((drawW !== srcW || drawH !== srcH) && scaleCanvas && scaleCtx) {
scaleCanvas.width = drawW
scaleCanvas.height = drawH
if ((drawW !== srcW || drawH !== srcH) && sampleCanvas && scaleCtx) {
sampleCanvas.width = drawW
sampleCanvas.height = drawH
scaleCtx.clearRect(0, 0, drawW, drawH)
scaleCtx.drawImage(image, 0, 0, drawW, drawH)
gl.texImage2D(
@@ -223,7 +257,7 @@ function createTextureManager(gl, canvas, scaleCanvas, viewportW, viewportH) {
gl.RGBA,
gl.RGBA,
gl.UNSIGNED_BYTE,
scaleCanvas
sampleCanvas
)
} else {
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image)
@@ -331,11 +365,23 @@ function createTextureManager(gl, canvas, scaleCanvas, viewportW, viewportH) {
entry.height = uploaded.height
entry.aspect = uploaded.aspect
const cloud = sampler.sample(entry, {
let cloud = sampler.sample(entry, {
maxPoints: maxPoints,
maxSide: sampleSide,
removeBackground: removeBackground,
})
// 采点过稀:降分辨率再采,仍保持去背景(效果图不要背景点云)
if (!cloud || cloud.count < Math.max(40, maxPoints * 0.01)) {
cloud = sampler.sample(entry, {
maxPoints: maxPoints,
maxSide: Math.min(sampleSide, POINT_CLOUD.SAMPLE_LOW),
removeBackground: removeBackground,
softKnockout: true,
})
}
if (!cloud || cloud.count < Math.max(80, Math.floor(maxPoints * 0.02))) {
throw new Error('point cloud sample too sparse: ' + (cloud && cloud.count))
}
entry.pointCloud = cloud
entry.pointCloudBuffer = uploadCloudBuffer(cloud)
entry.preloadStatus = 'ready'