Skip to content

Commit fda0539

Browse files
committed
chore: sync v1.2.9 (html-export, electron file writer, screen projection)
1 parent e9ae1ab commit fda0539

45 files changed

Lines changed: 3221 additions & 487 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.gitignore‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,4 @@ native/build
1717
*.log
1818
Thumbs.db
1919
desktop.ini
20+
skills-lock.json

‎debug-browser-import-lag.md‎

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
# Debug Session: browser-import-lag
2+
3+
**状态**: [CLOSED]
4+
**创建时间**: 2026-08-08
5+
**症状**: 浏览器导入任何高斯文件后持续卡顿(FPS 低);Electron 正常
6+
**复现步骤**: 打开浏览器版应用 → 导入高斯文件 → 旋转/缩放相机 → 持续卡顿
7+
8+
## 环境
9+
- OS: Windows
10+
- 浏览器: (待用户确认)
11+
- 应用: ReSplat (supersplat-2.27.0 base, PlayCanvas 2.18.2)
12+
- Electron 正常, 浏览器卡顿
13+
14+
## 结论 (2026-08-08 实测)
15+
16+
**根因(最终确认):Trae CN 的 GPU 进程强制 `--use-angle=swiftshader-webgl`,导致内置浏览器所有 WebGL(含 ReSplat)走 SwiftShader 纯软件渲染,比硬件慢约 40 倍。非应用逻辑/版本问题。**
17+
18+
证据:
19+
- 控制台日志(Trae 内置浏览器):`[ReSplat] WebGL renderer: ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device (Subzero)), SwiftShader driver)`
20+
- Trae CN GPU 进程命令行:`--type=gpu-process ... --use-gl=angle --use-angle=swiftshader-webgl`(应用内置,argv.json/快捷方式均无此配置)
21+
- TRAE SOLO CN GPU 进程**无** swiftshader 参数 → 硬件加速,不卡
22+
- `c:\Users\123\.trae-cn\argv.json` 无 `disable-hardware-acceleration`
23+
24+
**实测(同一 release 构建、50k 合成高斯)**:
25+
| 渲染环境 | 渲染器 | 导入后帧耗时 |
26+
|---|---|---|
27+
| Edge 硬件 GL | NVIDIA RTX 5060 Ti D3D11 | 17.6ms (57fps) |
28+
| 强制软件 GL (模拟 Trae 内置浏览器) | SwiftShader (Subzero) | **3776ms (0.3fps)** |
29+
30+
- 空闲时 60fps、renderNextFrame=false、sorter worker 健康(1-2ms)、无内存压力 → 假设 A/B/D/E 均排除。
31+
- 旧版 1.2.4(备份)与新版 1.2.7 帧耗时几乎一致 → 非版本回归。
32+
33+
### 建议
34+
- 交互式编辑请使用 **Edge 浏览器 / Electron**(硬件加速)。
35+
- Trae 内置浏览器因内置 swiftshader-webgl 参数无法用于 GPU 重负载应用。
36+
37+
### 已实施修复 (2026-08-08)
38+
在 `src/main.ts` 启动时检测 WebGL 渲染器(`WEBGL_debug_renderer_info`):
39+
- 检测到软件渲染(SwiftShader/llvmpipe)→ 控制台输出 + 弹窗提示"开启硬件加速或改用 Edge/Electron"。
40+
- 注:曾尝试自动 `pixelScale=2` 降分辨率,用户反馈视口变糊且对软件渲染帮助有限,**已撤销**,仅保留检测+提示。
41+
- 新增 locale 键:`popup.software-rendering.title/message`(en/zh-CN)。
42+
43+
### 说明
44+
- 当前 dist 已用 `npm run build` 重建为 release(之前是 develop 的 debug 引擎构建 playcanvas.dbg)。
45+
- 临时调试文件(scripts/diag-*.cjs、.diag-edge-profile、%TEMP%\resplat-temp\diag_*.ply)已清理。

‎electron/main.cjs‎

Lines changed: 55 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
const { app: electronApp, BrowserWindow, ipcMain, dialog, shell, Menu, protocol, net } = require('electron');
1010
const path = require('path');
1111
const crypto = require('crypto');
12+
const { extractGzipProjectEntry } = require('./project-extractor.cjs');
1213

1314
// ---------------------------------------------------------------------------
1415
// Raise renderer V8 heap limit above the ~4 GB default
@@ -27,7 +28,12 @@ const crypto = require('crypto');
2728
// [4 GB, 16 GB], to avoid triggering swap on memory-constrained machines.
2829
const totalMemMB = Math.floor(require('os').totalmem() / 1024 / 1024);
2930
const rendererMaxOldSpace = Math.min(16384, Math.max(4096, Math.floor((totalMemMB - 2048) * 0.75)));
30-
electronApp.commandLine.appendSwitch('js-flags', `--max-old-space-size=${rendererMaxOldSpace}`);
31+
// --expose-gc enables global.gc() in the renderer. The LCC2/HTML export paths
32+
// call (globalThis).gc?.() to release multi-GB intermediate DataTables between
33+
// LOD loads (serializeLcc2FromLodLog, html export). Without the flag those
34+
// hints silently no-op and previous LOD tables stay resident until the next
35+
// multi-GB allocation throws "Array buffer allocation failed".
36+
electronApp.commandLine.appendSwitch('js-flags', `--expose-gc --max-old-space-size=${rendererMaxOldSpace}`);
3137
console.log(`[electron] Renderer V8 heap limit: ${rendererMaxOldSpace} MB (system RAM: ${totalMemMB} MB)`);
3238

3339
// ---------------------------------------------------------------------------
@@ -173,6 +179,14 @@ async function createWindow() {
173179
console.log('[electron] WebGPU check failed:', e.message);
174180
}
175181

182+
// Forward renderer errors to the main-process terminal so export failures
183+
// can be diagnosed without opening DevTools.
184+
mainWindow.webContents.on('console-message', (_e, level, message, line, sourceId) => {
185+
if (typeof message === 'string' && level >= 3) {
186+
console.log(`[renderer] ${message}${sourceId ? ` (${sourceId}:${line})` : ''}`);
187+
}
188+
});
189+
176190
mainWindow.on('closed', () => {
177191
mainWindow = null;
178192
});
@@ -236,6 +250,30 @@ ipcMain.handle('fs:readFile', async (_event, filePath) => {
236250
return fs.readFileSync(filePath);
237251
});
238252

253+
// Read a byte range from a file by absolute path + position. Used by the LCC
254+
// reader to stream GB-scale sibling files (data.bin/shcoef.bin) on demand
255+
// WITHOUT going through Chromium's network stack — system proxies / flaky
256+
// localhost routing made fetch-based reads (HTTP Range) unreliable, and IPC
257+
// readFileSync of the whole file blows the renderer's ~4GB memory limit.
258+
ipcMain.handle('fs:readRange', async (_event, filePath, position, length) => {
259+
const fs = require('fs');
260+
const fd = fs.openSync(filePath, 'r');
261+
try {
262+
const buf = Buffer.allocUnsafe(length);
263+
const bytesRead = fs.readSync(fd, buf, 0, length, position);
264+
return buf.subarray(0, bytesRead);
265+
} finally {
266+
fs.closeSync(fd);
267+
}
268+
});
269+
270+
// Stat a file; returns { size } (bytes)
271+
ipcMain.handle('fs:stat', async (_event, filePath) => {
272+
const fs = require('fs');
273+
const s = fs.statSync(filePath);
274+
return { size: s.size };
275+
});
276+
239277
// Check if a file path exists
240278
ipcMain.handle('fs:exists', async (_event, filePath) => {
241279
const fs = require('fs');
@@ -250,9 +288,11 @@ ipcMain.handle('fs:readDir', async (_event, dirPath) => {
250288
return fs.readdirSync(dirPath).map(name => path.join(dirPath, name));
251289
});
252290

253-
// Recursively walk a directory tree. Returns [{ path, rel }] for every regular
254-
// file, where `path` is absolute and `rel` is the path relative to `dirPath`
255-
// using forward slashes (so it matches web FileSystemEntry fullPath conventions).
291+
// Recursively walk a directory tree. Returns [{ path, rel, size }] for every
292+
// regular file, where `path` is absolute, `rel` is the path relative to `dirPath`
293+
// using forward slashes (so it matches web FileSystemEntry fullPath conventions),
294+
// and `size` is the file size in bytes. The size lets the renderer skip
295+
// GB-scale LCC siblings (data.bin/shcoef.bin) instead of shipping them over IPC.
256296
// Used to auto-resolve LCC2 chunk siblings (.sog/.spz) that live in subdirectories
257297
// next to a dropped .lcc2 meta file.
258298
ipcMain.handle('fs:walk', async (_event, dirPath) => {
@@ -271,7 +311,7 @@ ipcMain.handle('fs:walk', async (_event, dirPath) => {
271311
if (stat.isDirectory()) {
272312
walk(full, rel);
273313
} else if (stat.isFile()) {
274-
result.push({ path: full, rel });
314+
result.push({ path: full, rel, size: stat.size });
275315
}
276316
}
277317
};
@@ -356,6 +396,16 @@ ipcMain.handle('fs:unlink', async (_event, p) => {
356396
}
357397
});
358398

399+
// Expand a .respproj PLY into a temporary file in the main process. Sending a
400+
// multi-GB project payload to the renderer first creates avoidable heap copies.
401+
ipcMain.handle('project:extractGzipEntry', async (_event, projectPath, entryName) => {
402+
if (typeof projectPath !== 'string' || typeof entryName !== 'string' || !/^splat_\d+\.ply\.gz$/.test(entryName)) {
403+
throw new Error('Invalid project extraction request');
404+
}
405+
const tempDir = path.join(electronApp.getPath('temp'), 'ReSplat-projects');
406+
return extractGzipProjectEntry(projectPath, entryName, tempDir);
407+
});
408+
359409
// Open URL in system default browser
360410
ipcMain.handle('shell:openExternal', async (_event, url) => {
361411
await shell.openExternal(url);

‎electron/preload.cjs‎

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,27 @@ const { contextBridge, ipcRenderer, webUtils } = require('electron');
1616
// 'drop' events in the preload context (capture phase) and extract paths
1717
// using the original File objects BEFORE they enter the renderer world.
1818
const _dropFilePaths = new Map();
19+
const _inputFilePaths = new Map();
20+
21+
// Chromium no longer exposes File.path to the isolated renderer. Capture
22+
// native paths while the FileList is still in the preload world, then let the
23+
// renderer resolve the path by the stable name+size pair. This is needed for
24+
// path-based backend exports of large .respproj files.
25+
const rememberInputFilePaths = (files) => {
26+
for (const file of files || []) {
27+
try {
28+
const path = webUtils.getPathForFile(file);
29+
if (path) _inputFilePaths.set(`${file.name}|${file.size}`, path);
30+
} catch (_err) {
31+
// ignore — file has no accessible path
32+
}
33+
}
34+
};
35+
36+
document.addEventListener('change', (event) => {
37+
const files = event.target?.files;
38+
if (files) rememberInputFilePaths(files);
39+
}, true);
1940

2041
document.addEventListener('drop', (e) => {
2142
_dropFilePaths.clear();
@@ -94,6 +115,14 @@ contextBridge.exposeInMainWorld('electronAPI', {
94115
/** Read a file from disk (by absolute path) */
95116
readFile: (filePath) => ipcRenderer.invoke('fs:readFile', filePath),
96117

118+
/** Read a byte range [position, position+length) from a file (by absolute path).
119+
* Used by the LCC reader to stream GB-scale siblings on demand over IPC —
120+
* avoids Chromium's proxy/network stack AND renderer OOM. */
121+
readRange: (filePath, position, length) => ipcRenderer.invoke('fs:readRange', filePath, position, length),
122+
123+
/** Stat a file; resolves to { size } (bytes) */
124+
fileSize: (filePath) => ipcRenderer.invoke('fs:stat', filePath),
125+
97126
/** Check if a file exists */
98127
fileExists: (filePath) => ipcRenderer.invoke('fs:exists', filePath),
99128

@@ -118,6 +147,9 @@ contextBridge.exposeInMainWorld('electronAPI', {
118147
/** Best-effort delete a file (used by writer abort cleanup); resolves to true on success */
119148
unlink: (filePath) => ipcRenderer.invoke('fs:unlink', filePath),
120149

150+
/** Extract a gzip PLY entry from a .respproj archive to a temporary file. */
151+
extractProjectGzipEntry: (projectPath, entryName) => ipcRenderer.invoke('project:extractGzipEntry', projectPath, entryName),
152+
121153
/** Get the current browser zoom factor */
122154
getZoomFactor: () => ipcRenderer.invoke('zoom:get'),
123155

@@ -148,6 +180,9 @@ contextBridge.exposeInMainWorld('electronAPI', {
148180
}
149181
},
150182

183+
/** Resolve a file-input path captured in preload before context cloning. */
184+
getInputFilePath: (name, size) => _inputFilePaths.get(`${name}|${size}`),
185+
151186
/**
152187
* Retrieve the filesystem path of a drag-dropped file.
153188
* Must be called from the renderer's drop handler (same tick) after a
@@ -179,4 +214,4 @@ contextBridge.exposeInMainWorld('electronAPI', {
179214
* Receives `docName` (string), should resolve to 'save' | 'discard' | 'cancel'.
180215
*/
181216
registerSavePromptHandler: (fn) => { closeHandlers.savePromptHandler = fn; }
182-
});
217+
});

‎electron/project-extractor.cjs‎

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
/**
2+
* Extract a gzip-compressed PLY stored in a .respproj ZIP archive directly to
3+
* a temporary file. Keeping this in the main process prevents the renderer
4+
* from materialising both the compressed and decompressed multi-GB payload.
5+
*/
6+
const fs = require('fs');
7+
const fsp = require('fs/promises');
8+
const path = require('path');
9+
const { pipeline } = require('stream/promises');
10+
const zlib = require('zlib');
11+
12+
const EOCD_SIGNATURE = 0x06054b50;
13+
const CENTRAL_DIRECTORY_SIGNATURE = 0x02014b50;
14+
const LOCAL_FILE_SIGNATURE = 0x04034b50;
15+
16+
const findEocdOffset = (buffer) => {
17+
for (let offset = buffer.length - 22; offset >= 0; offset--) {
18+
if (buffer.readUInt32LE(offset) === EOCD_SIGNATURE) return offset;
19+
}
20+
throw new Error('Invalid project archive: end of central directory not found');
21+
};
22+
23+
const readZipEntry = async (archivePath, entryName) => {
24+
const { size } = await fsp.stat(archivePath);
25+
const tailSize = Math.min(size, 65536 + 22);
26+
const tail = Buffer.alloc(tailSize);
27+
const handle = await fsp.open(archivePath, 'r');
28+
29+
try {
30+
await handle.read(tail, 0, tailSize, size - tailSize);
31+
const eocd = findEocdOffset(tail);
32+
const entryCount = tail.readUInt16LE(eocd + 10);
33+
const directorySize = tail.readUInt32LE(eocd + 12);
34+
const directoryOffset = tail.readUInt32LE(eocd + 16);
35+
const directory = Buffer.alloc(directorySize);
36+
await handle.read(directory, 0, directorySize, directoryOffset);
37+
38+
let offset = 0;
39+
for (let i = 0; i < entryCount; i++) {
40+
if (offset + 46 > directory.length || directory.readUInt32LE(offset) !== CENTRAL_DIRECTORY_SIGNATURE) {
41+
throw new Error('Invalid project archive: malformed central directory');
42+
}
43+
const flags = directory.readUInt16LE(offset + 8);
44+
const compressionMethod = directory.readUInt16LE(offset + 10);
45+
const compressedSize = directory.readUInt32LE(offset + 20);
46+
const nameLength = directory.readUInt16LE(offset + 28);
47+
const extraLength = directory.readUInt16LE(offset + 30);
48+
const commentLength = directory.readUInt16LE(offset + 32);
49+
const localHeaderOffset = directory.readUInt32LE(offset + 42);
50+
const encoding = (flags & 0x800) !== 0 ? 'utf8' : 'ascii';
51+
const name = directory.toString(encoding, offset + 46, offset + 46 + nameLength);
52+
53+
if (name === entryName) {
54+
const localHeader = Buffer.alloc(30);
55+
await handle.read(localHeader, 0, localHeader.length, localHeaderOffset);
56+
if (localHeader.readUInt32LE(0) !== LOCAL_FILE_SIGNATURE) {
57+
throw new Error('Invalid project archive: malformed local file header');
58+
}
59+
const localNameLength = localHeader.readUInt16LE(26);
60+
const localExtraLength = localHeader.readUInt16LE(28);
61+
return {
62+
compressionMethod,
63+
compressedSize,
64+
dataOffset: localHeaderOffset + 30 + localNameLength + localExtraLength
65+
};
66+
}
67+
offset += 46 + nameLength + extraLength + commentLength;
68+
}
69+
} finally {
70+
await handle.close();
71+
}
72+
73+
throw new Error(`Invalid project archive: entry "${entryName}" not found`);
74+
};
75+
76+
const extractGzipProjectEntry = async (archivePath, entryName, outputDir) => {
77+
const entry = await readZipEntry(archivePath, entryName);
78+
if (entry.compressionMethod !== 0 && entry.compressionMethod !== 8) {
79+
throw new Error(`Unsupported ZIP compression method ${entry.compressionMethod}`);
80+
}
81+
82+
await fsp.mkdir(outputDir, { recursive: true });
83+
const outputPath = path.join(outputDir, `${Date.now()}-${process.pid}-${Math.random().toString(16).slice(2)}.ply`);
84+
const input = fs.createReadStream(archivePath, {
85+
start: entry.dataOffset,
86+
end: entry.dataOffset + entry.compressedSize - 1
87+
});
88+
const transforms = entry.compressionMethod === 8 ? [zlib.createInflateRaw()] : [];
89+
90+
try {
91+
await pipeline(input, ...transforms, zlib.createGunzip(), fs.createWriteStream(outputPath, { flags: 'wx' }));
92+
const { size } = await fsp.stat(outputPath);
93+
return { path: outputPath, size };
94+
} catch (error) {
95+
await fsp.rm(outputPath, { force: true }).catch(() => {});
96+
throw error;
97+
}
98+
};
99+
100+
module.exports = { extractGzipProjectEntry };

‎global.d.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ interface ElectronAPI {
2727
openFolderDialog: () => Promise<string | null>;
2828
saveFileDialog: (options?: { title?: string; defaultPath?: string; filters?: Array<{ name: string; extensions: string[] }> }) => Promise<string | null>;
2929
readFile: (filePath: string) => Promise<Uint8Array>;
30+
readRange: (filePath: string, position: number, length: number) => Promise<Uint8Array>;
31+
fileSize: (filePath: string) => Promise<{ size: number }>;
3032
fileExists: (filePath: string) => Promise<boolean>;
3133
readDir: (dirPath: string) => Promise<string[]>;
3234
/** Create a directory (recursive when opts.recursive is true) */
@@ -39,6 +41,7 @@ interface ElectronAPI {
3941
writeStreamClose: (id: number) => Promise<void>;
4042
/** Best-effort delete a file (used by writer abort cleanup) */
4143
unlink: (filePath: string) => Promise<boolean>;
44+
extractProjectGzipEntry: (projectPath: string, entryName: string) => Promise<{ path: string, size: number }>;
4245
/** Register a callback returning { dirty, docName } for close-time save prompt */
4346
registerDirtyChecker: (fn: () => Promise<{ dirty: boolean; docName: string | null }>) => void;
4447
/** Register a callback that saves the current doc; resolves true on success */

‎package.json‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "ReSplat",
3-
"version": "1.2.8",
3+
"version": "1.2.9",
44
"main": "electron/main.cjs",
55
"author": "Re祁",
66
"homepage": "https://playcanvas.com/ReSplat/editor",
@@ -78,6 +78,7 @@
7878
"directories": {
7979
"output": "dist-release"
8080
},
81+
"artifactName": "ReSplat Setup ${version}.${ext}",
8182
"files": [
8283
"dist/**/*",
8384
"electron/**/*",

0 commit comments

Comments
 (0)