修改:优化手机端效果图点云效果
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) { function normalizeOssUrl(url: string) {
if (!url) return '' 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) { function guessContentType(file: File | Blob, filename?: string) {
@@ -186,9 +186,9 @@ function preloadResult(url: string) {
if (!url || !renderer || !sm) return if (!url || !renderer || !sm) return
resultKey = url resultKey = url
sm.setResultPreloadStatus('loading') sm.setResultPreloadStatus('loading')
// 效果图保留完整画面做点云(去背景会在手机上采空,表现为「没有效果图点云」) // 效果图点云去掉背景,只保留主体
renderer.textures renderer.textures
.load(url, { removeBackground: false }) .load(url, { removeBackground: true })
.then((entry: any) => { .then((entry: any) => {
if (destroyed || !sm) return if (destroyed || !sm) return
if (!entry || !entry.pointCloudBuffer) { if (!entry || !entry.pointCloudBuffer) {
@@ -428,14 +428,16 @@ onUnmounted(() => {
opacity: 0; opacity: 0;
} }
/* 仅占位;真实采样已改用离屏 canvas,勿再用 CSS 锁尺寸 */
.ipc-scale { .ipc-scale {
position: absolute !important; position: absolute !important;
width: 1px !important; left: -9999px !important;
height: 1px !important; top: 0 !important;
width: 2px;
height: 2px;
opacity: 0; opacity: 0;
pointer-events: none; pointer-events: none;
left: -9999px !important; visibility: hidden;
top: auto !important;
} }
.ipc-fallback { .ipc-fallback {
@@ -46,11 +46,11 @@ function createPointCloudSampler(scaleCanvas, viewportW, viewportH, sharedCtx) {
const forceSampleSize = options.forceSampleSize || null const forceSampleSize = options.forceSampleSize || null
const image = entry.image 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') throw new Error('image not ready for sampling')
} }
const srcW = image.width
const srcH = image.height
let w let w
let h let h
if (forceSampleSize && forceSampleSize.width && forceSampleSize.height) { if (forceSampleSize && forceSampleSize.width && forceSampleSize.height) {
@@ -152,13 +152,20 @@ function createPointCloudSampler(scaleCanvas, viewportW, viewportH, sharedCtx) {
const dg = g - bgG const dg = g - bgG
const db = b - bgB const db = b - bgB
const distance = Math.sqrt(dr * dr + dg * dg + db * db) / 441.67 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 chroma = Math.abs(r - g) + Math.abs(g - b) + Math.abs(r - b)
const nearBlack = lum < 0.07 && chroma < 16 const bgLum = luminance(bgR, bgG, bgB)
if (nearBlack) continue // 仅亮底时丢弃近黑噪点,避免黑底效果图误杀主体暗部
const darkKeep = Math.max(0, Math.min(1, (0.62 - lum) / 0.45)) if (bgLum > 0.35) {
const softKeep = Math.max(0, Math.min(1, (distance - 0.015) / 0.07)) const nearBlack = lum < 0.07 && chroma < 16
presence = Math.max(notBackground, darkKeep * 0.5, softKeep * 0.4) * a if (nearBlack) continue
}
presence = notBackground * a
subjectWeight = 1 subjectWeight = 1
} }
@@ -235,11 +235,41 @@ function createRenderer(canvas, cssW, cssH, dpr, scaleCanvas) {
gl.clear(gl.COLOR_BUFFER_BIT) gl.clear(gl.COLOR_BUFFER_BIT)
const pointOpacity = u.opacity != null ? u.opacity : 1 const pointOpacity = u.opacity != null ? u.opacity : 1
const reveal = u.reveal != null ? u.reveal : 0
// 完整效果图改由页面层按预览框位展示,避免 WebGL 全屏图与预览错位 gl.enable(gl.BLEND)
if (pointOpacity > 0.001 && cloudA && cloudB) { 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) gl.useProgram(pointProgram)
bindCloudAttribs( bindCloudAttribs(
@@ -14,23 +14,27 @@ function createTextureManager(gl, canvas, scaleCanvas, viewportW, viewportH) {
let maxTexSize = VISUAL.MAX_TEX_SIZE let maxTexSize = VISUAL.MAX_TEX_SIZE
let maxPoints = POINT_CLOUD.MEDIUM let maxPoints = POINT_CLOUD.MEDIUM
let sampleSide = POINT_CLOUD.SAMPLE_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 { try {
// getImageData 频繁读回:声明 willReadFrequently 消除告警并提升性能 scaleCtx = sampleCanvas.getContext('2d')
scaleCtx = scaleCanvas.getContext('2d', { willReadFrequently: true }) } catch (e2) {
} catch (e) { scaleCtx = null
try {
scaleCtx = scaleCanvas.getContext('2d')
} catch (e2) {
scaleCtx = null
}
} }
} }
// 保留入参 scaleCanvas 作兼容,实际采样/缩放一律走离屏 canvas
void scaleCanvas
const sampler = createPointCloudSampler( const sampler = createPointCloudSampler(
scaleCanvas, sampleCanvas,
viewportW, viewportW,
viewportH, viewportH,
scaleCtx 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 之后再交给后续逻辑。 * 加载图片并严格在 onload 之后再交给后续逻辑。
* http(s) 优先 fetch→blob,避免手机 WebView 跨域导致 getImageData 采点失败。 * http(s) 优先 fetch→blob,避免手机 WebView 跨域导致 getImageData 采点失败。
*/ */
function loadImage(src) { function loadImage(rawSrc) {
const src = normalizeImageSrc(rawSrc)
return new Promise(function (resolve, reject) { return new Promise(function (resolve, reject) {
if (!src) { if (!src) {
reject(new Error('empty src')) reject(new Error('empty src'))
@@ -136,8 +149,8 @@ function createTextureManager(gl, canvas, scaleCanvas, viewportW, viewportH) {
if (settled) return if (settled) return
setTimeout(function () { setTimeout(function () {
if (settled) return if (settled) return
const w = img.width || 0 const w = img.naturalWidth || img.width || 0
const h = img.height || 0 const h = img.naturalHeight || img.height || 0
if (!w || !h) { if (!w || !h) {
settled = true settled = true
if (revokeUrl) { if (revokeUrl) {
@@ -173,15 +186,36 @@ function createTextureManager(gl, canvas, scaleCanvas, viewportW, viewportH) {
// 远程图:先 blob 同源化,再采样(手机端效果图点云依赖此路径) // 远程图:先 blob 同源化,再采样(手机端效果图点云依赖此路径)
if (/^https?:\/\//i.test(src) && typeof fetch === 'function') { 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) { .then(function (res) {
if (!res || !res.ok) throw new Error('fetch image http ' + (res && res.status)) if (!res || !res.ok) throw new Error('fetch image http ' + (res && res.status))
return res.blob() return res.blob()
}) })
.then(function (blob) { .then(function (blob) {
if (!blob || !blob.size) throw new Error('empty image 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) const objUrl = URL.createObjectURL(blob)
bindImage(objUrl, objUrl) bindImage(objUrl, objUrl)
return null
}) })
.catch(function () { .catch(function () {
// CORS 拉流失败时退回 <img crossOrigin> // CORS 拉流失败时退回 <img crossOrigin>
@@ -195,11 +229,11 @@ function createTextureManager(gl, canvas, scaleCanvas, viewportW, viewportH) {
} }
function uploadTexture(image, path) { 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') throw new Error('uploadTexture: image not ready')
} }
const srcW = image.width || 1 const srcW = image.naturalWidth || image.width || 1
const srcH = image.height || 1 const srcH = image.naturalHeight || image.height || 1
let drawW = srcW let drawW = srcW
let drawH = srcH let drawH = srcH
const longest = Math.max(srcW, srcH) const longest = Math.max(srcW, srcH)
@@ -212,9 +246,9 @@ function createTextureManager(gl, canvas, scaleCanvas, viewportW, viewportH) {
const tex = createEmptyTexture() const tex = createEmptyTexture()
gl.bindTexture(gl.TEXTURE_2D, tex) gl.bindTexture(gl.TEXTURE_2D, tex)
try { try {
if ((drawW !== srcW || drawH !== srcH) && scaleCanvas && scaleCtx) { if ((drawW !== srcW || drawH !== srcH) && sampleCanvas && scaleCtx) {
scaleCanvas.width = drawW sampleCanvas.width = drawW
scaleCanvas.height = drawH sampleCanvas.height = drawH
scaleCtx.clearRect(0, 0, drawW, drawH) scaleCtx.clearRect(0, 0, drawW, drawH)
scaleCtx.drawImage(image, 0, 0, drawW, drawH) scaleCtx.drawImage(image, 0, 0, drawW, drawH)
gl.texImage2D( gl.texImage2D(
@@ -223,7 +257,7 @@ function createTextureManager(gl, canvas, scaleCanvas, viewportW, viewportH) {
gl.RGBA, gl.RGBA,
gl.RGBA, gl.RGBA,
gl.UNSIGNED_BYTE, gl.UNSIGNED_BYTE,
scaleCanvas sampleCanvas
) )
} else { } else {
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image) 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.height = uploaded.height
entry.aspect = uploaded.aspect entry.aspect = uploaded.aspect
const cloud = sampler.sample(entry, { let cloud = sampler.sample(entry, {
maxPoints: maxPoints, maxPoints: maxPoints,
maxSide: sampleSide, maxSide: sampleSide,
removeBackground: removeBackground, 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.pointCloud = cloud
entry.pointCloudBuffer = uploadCloudBuffer(cloud) entry.pointCloudBuffer = uploadCloudBuffer(cloud)
entry.preloadStatus = 'ready' entry.preloadStatus = 'ready'