鸿蒙PC平台 PyRayTrace 光线追踪渲染器适配实战:基于 Electron 壳方案的纯 JavaScript Whitted 风格递归反射引擎开发
鸿蒙PC平台 PyRayTrace 光线追踪渲染器适配实战:基于 Electron 壳方案的纯 JavaScript Whitted 风格递归反射引擎开发
PyRayTrace 是一款纯 JavaScript 实现的光线追踪渲染器,基于经典 Whitted 风格算法,支持直接光照、阴影射线、递归反射与 Halton 低差异序列抗锯齿。本文记录在鸿蒙 PC 平台上,基于 Electron 壳方案从零构建一个具备渐进式逐行渲染、多场景切换、可调参数面板与 PNG 导出的光线追踪渲染器的完整过程——涵盖三维向量运算、射线-几何体求交、材质系统、相机模型、渐进式渲染调度、鸿蒙稳定性适配等核心功能的实现细节与踩坑经验。
欢迎加入开源鸿蒙 PC 社区:https://harmonypc.csdn.net/
欢迎在 PC 社区平台申请新建项目:https://atomgit.com/OpenHarmonyPCDeveloper
AtomGit 仓库地址:https://atomgit.com/OpenHarmonyPCDeveloper/ohos_RayTracePy
一、技术架构分析
1.1 整体架构
本项目采用 Electron Web 层 + 鸿蒙 HAP 壳工程的分层架构:
- Electron Web 层:运行于 ArkWeb 引擎,提供完整的光线追踪计算、Canvas 逐行渲染、参数交互与图像导出能力
- 鸿蒙 HAP 壳工程:通过 web_engine 模块加载 Web 应用,提供窗口管理、文件系统访问、屏幕自适应等系统能力
这种架构的核心优势在于:业务代码与平台完全解耦,Electron 部分可以在任何平台独立运行,鸿蒙壳仅负责适配和打包。光线追踪引擎为纯 JavaScript 实现,不依赖任何第三方库或 WebGL,所有渲染通过 Canvas 2D API 逐行写入像素数据。
1.2 技术方案
| 对比项 | Python 原版(py-raytrace) | 鸿蒙适配版(PyRayTrace) |
|---|---|---|
| 渲染算法 | Whitted 风格光线追踪 | 同算法 JavaScript 移植 |
| 采样方式 | Halton 低差异序列 | 同算法 JavaScript 实现 |
| 渲染输出 | 文件保存 | Canvas 2D 逐行渐进式渲染 |
| 开发语言 | Python | HTML5/Canvas/Vanilla JavaScript |
| 文件操作 | 本地文件 I/O | Node.js fs 模块 + IPC |
| 部署方式 | pip 安装运行 | HAP 包(鸿蒙应用) |
| 运行环境 | Windows/macOS/Linux | 鸿蒙 PC(ArkWeb 引擎) |
1.3 核心功能清单
| 功能模块 | 具体能力 |
|---|---|
| 光线追踪引擎 | 三维向量运算、射线-球体/平面求交、递归反射、阴影射线 |
| 材质系统 | Lambert 漫反射、Metal 金属反射(含 fuzz 模糊)、Emissive 自发光 |
| 预设场景 | 经典三球、金属矩阵(3×3 Halton 抖动)、光池夜景(自发光照明) |
| 渐进式渲染 | 逐行渲染 + 分批让出主线程,保持 UI 实时可交互 |
| 抗锯齿 | Halton(2,3) 低差异序列亚像素采样,1/2/4/9 采样可选 |
| 参数面板 | 分辨率、采样数、反射深度、视场角 FOV、每帧行数实时调节 |
| PNG 导出 | Canvas → Base64 → Node.js fs 真实文件写入 |
| 实时统计 | 射线总数、射线/秒速度、渲染耗时、进度百分比 |
| 鸿蒙稳定 | 自定义关于弹窗 + 三防窗口配置 + 禁用原生菜单 |
二、环境准备
2.1 开发环境要求
| 项目 | 版本/信息 |
|---|---|
| 操作系统 | Windows 10/11 |
| 核心框架 | Electron (Node.js + Chromium) |
| 技术栈 | HTML5/Canvas 2D/Vanilla JavaScript |
| 渲染引擎 | 纯 JavaScript 自研光线追踪 |
| 目标设备 | 鸿蒙 PC |
| 目标架构 | arm64-v8a |
| 开发工具 | DevEco Studio(鸿蒙官方 IDE) |
2.2 项目结构
ohos_hap/
├── electron-apps/
│ └── PyRayTrace/ # PyRayTrace 应用(开发目录)
│ ├── package.json # 项目配置
│ ├── main.js # 主进程(窗口管理 + 图像导出 IPC)
│ ├── index.html # 页面布局(工具栏 + 参数面板 + 画布 + 状态栏)
│ ├── renderer.js # 渲染进程(参数交互 + 渐进式渲染调度 + PNG 导出)
│ ├── raytrace-engine.js # 光线追踪引擎(向量运算 + 求交 + 材质 + 场景)
│ └── styles/
│ └── raytrace.css # Catppuccin Mocha 深色主题样式
├── web_engine/ # 鸿蒙壳工程
│ └── src/main/resources/resfile/resources/app/ # 部署目录
└── electron/ # Electron 壳
2.3 依赖说明
本项目零第三方库依赖,所有功能均由纯 JavaScript 手写实现:
| 模块 | 用途 |
|---|---|
| raytrace-engine.js | 光线追踪引擎:向量运算、射线求交、材质、场景 |
| renderer.js | 渲染进程:UI 交互、渲染调度、PNG 导出 |
| Canvas 2D API | 逐行写入 ImageData 像素数据 |
| Node.js fs | 通过 IPC 将 Base64 图像写入本地 PNG 文件 |
三、核心适配流程
3.1 主进程实现(main.js)
主进程负责窗口创建和图像导出 IPC。鸿蒙平台需要特殊的稳定性配置。
// PyRayTrace - 主进程
var electron = require('electron');
var app = electron.app;
var BrowserWindow = electron.BrowserWindow;
var screen = electron.screen;
var ipcMain = electron.ipcMain;
var dialog = electron.dialog;
var Menu = electron.Menu;
var fs = require('fs');
// 防 GPU 白屏:禁用硬件加速
app.disableHardwareAcceleration();
var mainWindow = null;
function createWindow() {
try {
// 移除默认菜单栏
Menu.setApplicationMenu(null);
var display = screen.getPrimaryDisplay();
mainWindow = new BrowserWindow({
width: Math.floor(display.workAreaSize.width * 0.92),
height: Math.floor(display.workAreaSize.height * 0.88),
frame: true,
transparent: false,
resizable: true,
webPreferences: { nodeIntegration: true, contextIsolation: false }
});
mainWindow.loadFile('index.html');
} catch (e) {
console.warn('[PyRayTrace] 创建窗口失败:', e.message);
}
}
app.whenReady().then(function() { createWindow(); });
app.on('window-all-closed', function() { app.quit(); });
关键配置说明:
- disableHardwareAcceleration:禁用硬件加速,避免鸿蒙 GPU 渲染白屏
- Menu.setApplicationMenu(null):移除默认菜单栏,鸿蒙上自定义 UI 更可控
- frame: true + transparent: false + resizable: true:鸿蒙三防配置,防止 XComponent 创建超时导致 SIGABRT 崩溃
- try-catch 包裹窗口创建:防止单点故障导致整个进程退出
IPC 通道注册:
主进程注册了 2 个 IPC 通道供渲染进程调用,分别用于弹出保存对话框和写入 PNG 文件:
// 弹出图像保存对话框
ipcMain.handle('image:saveDialog', function() {
return dialog.showSaveDialog(mainWindow, {
title: '导出渲染图像',
defaultPath: 'raytrace.png',
filters: [{ name: 'PNG 图像', extensions: ['png'] }]
});
});
// 将 Base64 数据写入 PNG 文件
ipcMain.handle('image:save', function(event, filePath, dataUrl) {
try {
var base64 = dataUrl.replace(/^data:image\/png;base64,/, '');
var buffer = Buffer.from(base64, 'base64');
fs.writeFileSync(filePath, buffer);
return { success: true, size: buffer.length };
} catch (e) {
return { success: false, error: e.message };
}
});
3.2 页面布局实现(index.html)
页面采用四段式布局:顶部工具栏 + 左侧参数面板 + 中央渲染画布 + 底部进度条与状态栏。
工具栏设计: 包含品牌标识、场景切换按钮、渲染/停止/导出/关于操作按钮。
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>PyRayTrace - 光线追踪渲染器</title>
<link rel="stylesheet" href="styles/raytrace.css">
</head>
<body>
<!-- 顶部工具栏 -->
<div class="toolbar">
<div class="toolbar-brand">PyRayTrace</div>
<div class="toolbar-separator"></div>
<div class="toolbar-group">
<span class="toolbar-label">场景</span>
<button class="scene-btn active" data-scene="classic">经典三球</button>
<button class="scene-btn" data-scene="metalGrid">金属矩阵</button>
<button class="scene-btn" data-scene="lightPool">光池夜景</button>
</div>
<div class="toolbar-spacer"></div>
<div class="toolbar-group">
<button class="btn-primary" id="btnRender">▶ 渲染</button>
<button class="btn-danger" id="btnStop" disabled>■ 停止</button>
<button class="tool-btn" id="btnExport" title="导出 PNG" disabled>💾 导出</button>
<button class="tool-btn" id="btnAbout" title="关于">ℹ 关于</button>
</div>
</div>
<!-- 主体区域 -->
<div class="main">
<!-- 左侧参数面板 -->
<div class="sidebar" id="sidebar">
<div class="sidebar-header">
<span class="sidebar-title">渲染参数</span>
</div>
<div class="sidebar-content">
<div class="param-group">
<label class="param-label">分辨率</label>
<div class="param-options" id="resOptions">
<button class="param-btn" data-res="320x240">320×240</button>
<button class="param-btn active" data-res="480x360">480×360</button>
<button class="param-btn" data-res="640x480">640×480</button>
</div>
</div>
<div class="param-group">
<label class="param-label">每像素采样(抗锯齿)</label>
<div class="param-options" id="sampleOptions">
<button class="param-btn" data-samples="1">1</button>
<button class="param-btn active" data-samples="2">2</button>
<button class="param-btn" data-samples="4">4</button>
<button class="param-btn" data-samples="9">9</button>
</div>
</div>
<div class="param-group">
<label class="param-label">最大反射深度 <span class="param-value" id="depthValue">4</span></label>
<input type="range" id="depthSlider" class="param-slider" min="1" max="8" step="1" value="4">
</div>
<div class="param-group">
<label class="param-label">视场角 FOV <span class="param-value" id="fovValue">60°</span></label>
<input type="range" id="fovSlider" class="param-slider" min="30" max="100" step="5" value="60">
</div>
<div class="param-group">
<label class="param-label">每帧行数(流畅度) <span class="param-value" id="batchValue">6</span></label>
<input type="range" id="batchSlider" class="param-slider" min="1" max="24" step="1" value="6">
</div>
</div>
<!-- 场景信息 -->
<div class="sidebar-header">
<span class="sidebar-title">场景信息</span>
</div>
<div class="sidebar-content" id="sceneInfo">
<div class="info-row"><span class="info-key">名称</span><span class="info-val" id="infoName">经典三球</span></div>
<div class="info-row"><span class="info-key">物体数</span><span class="info-val" id="infoObjects">5</span></div>
<div class="info-row"><span class="info-key">光源数</span><span class="info-val" id="infoLights">2</span></div>
<div class="info-row"><span class="info-key">相机</span><span class="info-val" id="infoCamera">(-4.5, 2.8, 5.5)</span></div>
</div>
</div>
<!-- 渲染画布区域 -->
<div class="viewport">
<div class="canvas-wrap" id="canvasWrap">
<canvas id="renderCanvas" width="480" height="360"></canvas>
<div class="canvas-empty" id="canvasEmpty">
<div class="empty-logo">PyRayTrace</div>
<div class="empty-subtitle">光线追踪渲染器</div>
<div class="empty-hint">选择场景后点击「渲染」开始逐行绘制</div>
</div>
</div>
</div>
</div>
<!-- 底部进度区 -->
<div class="progress-panel">
<div class="progress-bar-wrap">
<div class="progress-bar" id="progressBar"></div>
</div>
<div class="progress-text" id="progressText">就绪</div>
</div>
<!-- 底部状态栏 -->
<div class="statusbar">
<div class="status-left">
<span class="status-item" id="statusScene">场景: 经典三球</span>
<span class="status-sep">|</span>
<span class="status-item" id="statusRes">480×360</span>
</div>
<div class="status-right">
<span class="status-item" id="statusRays">射线: 0</span>
<span class="status-sep">|</span>
<span class="status-item" id="statusSpeed">0 射线/秒</span>
<span class="status-sep">|</span>
<span class="status-item" id="statusTime">耗时 0.0s</span>
</div>
</div>
<script src="raytrace-engine.js"></script>
<script src="renderer.js"></script>
</body>
</html>
布局说明:
- 左侧参数面板固定宽度 250px,包含分辨率/采样数按钮组、反射深度/FOV/每帧行数滑块
- 中央画布区域自适应填充,Canvas 使用 image-rendering: pixelated 保持像素锐利
- 底部进度条实时显示渲染百分比,状态栏显示射线数、速度、耗时
- 关于弹窗使用纯 div + overlay 实现,避免原生弹窗崩溃
3.3 光线追踪引擎(raytrace-engine.js)
光线追踪引擎是整个应用的核心,完全用纯 JavaScript 实现,不依赖任何第三方库。引擎包含向量运算、射线定义、几何体求交、材质系统、光照追踪、相机模型和预设场景七大模块。
三维向量运算:
所有三维几何运算基于手写 vec3 函数族,包含加减乘、点积、叉积、归一化、反射等基础操作:
// 向量构造与基础运算
function vec3(x, y, z) { return { x: x || 0, y: y || 0, z: z || 0 }; }
function vAdd(a, b) { return vec3(a.x + b.x, a.y + b.y, a.z + b.z); }
function vSub(a, b) { return vec3(a.x - b.x, a.y - b.y, a.z - b.z); }
function vMul(a, s) { return vec3(a.x * s, a.y * s, a.z * s); }
function vDot(a, b) { return a.x * b.x + a.y * b.y + a.z * b.z; }
function vCross(a, b) {
return vec3(a.y * b.z - a.z * b.y, a.z * b.x - a.x * b.z, a.x * b.y - a.y * b.x);
}
function vLen(a) { return Math.sqrt(vDot(a, a)); }
function vNorm(a) {
var len = vLen(a);
if (len === 0) return vec3(0, 0, 0);
return vec3(a.x / len, a.y / len, a.z / len);
}
function vReflect(v, n) { return vSub(v, vMul(n, 2 * vDot(v, n))); }
Halton 低差异序列(确定性采样):
抗锯齿使用 Halton 序列生成亚像素偏移,相比随机采样具有更好的均匀性和可复现性:
function halton(index, base) {
var f = 1, r = 0;
while (index > 0) {
f = f / base;
r = r + f * (index % base);
index = Math.floor(index / base);
}
return r;
}
材质系统:
引擎支持三种材质类型——Lambert 漫反射(含 Blinn-Phong 高光)、Metal 金属反射(含 fuzz 模糊)、Emissive 自发光:
// 漫反射材质
function makeLambert(albedo, specular, shininess) {
return { type: 'lambert', albedo: albedo, specular: specular || 0.1, shininess: shininess || 32 };
}
// 金属材质:reflectivity 反射率 0~1,fuzz 模糊度
function makeMetal(albedo, reflectivity, fuzz) {
return { type: 'metal', albedo: albedo, reflectivity: reflectivity, fuzz: fuzz || 0 };
}
// 自发光材质(用于光源球体)
function makeEmissive(emission) {
return { type: 'emissive', albedo: emission, emission: emission };
}
射线-几何体求交:
支持球体(二次方程判别式法)和平面(法线点积法)两种几何体,intersectScene 遍历所有物体返回最近交点:
// 射线与球体求交(二次方程判别式法)
function intersectSphere(ray, sphere) {
var oc = vSub(ray.origin, sphere.center);
var a = vDot(ray.direction, ray.direction);
var b = 2 * vDot(oc, ray.direction);
var c = vDot(oc, oc) - sphere.radius * sphere.radius;
var disc = b * b - 4 * a * c;
if (disc < 0) return null;
var sq = Math.sqrt(disc);
var t1 = (-b - sq) / (2 * a);
var t2 = (-b + sq) / (2 * a);
var t = t1 > 1e-4 ? t1 : (t2 > 1e-4 ? t2 : -1);
if (t < 0) return null;
var point = rayAt(ray, t);
var normal = vNorm(vSub(point, sphere.center));
return makeHit(t, point, normal, sphere.material);
}
// 射线与平面求交
function intersectPlane(ray, plane) {
var denom = vDot(plane.normal, ray.direction);
if (Math.abs(denom) < 1e-6) return null;
var t = vDot(vSub(plane.point, ray.origin), plane.normal) / denom;
if (t < 1e-4) return null;
return makeHit(t, rayAt(ray, t), plane.normal, plane.material);
}
递归光照追踪(traceRay):
核心追踪函数实现 Whitted 风格算法:直接光照(Lambert 漫反射 + Blinn-Phong 高光)+ 阴影射线测试 + 递归反射(金属材质),自发光体不遮挡光线:
function traceRay(scene, ray, depth, stats) {
stats.rays++;
var hit = intersectScene(ray, scene.objects);
if (!hit) return skyColor(scene, ray.direction);
var mat = hit.material;
if (mat.type === 'emissive') return mat.emission;
var normal = vDot(hit.normal, ray.direction) < 0 ? hit.normal : vNeg(hit.normal);
var viewDir = vNeg(ray.direction);
var color = vec3(0, 0, 0);
// 直接光照:遍历所有点光源
for (var i = 0; i < scene.lights.length; i++) {
var light = scene.lights[i];
if (inShadow(scene, vAdd(hit.point, vMul(normal, 1e-3)), light.position)) continue;
var lightDir = vNorm(vSub(light.position, hit.point));
var intensity = light.intensity * Math.max(0, vDot(normal, lightDir))
/ (1 + 0.05 * vLen(vSub(light.position, hit.point)));
color = vAdd(color, vMulV(vMul(mat.albedo, intensity), light.color));
// Blinn-Phong 高光(半程向量)
var halfVec = vNorm(vAdd(lightDir, viewDir));
var spec = Math.pow(Math.max(0, vDot(normal, halfVec)), mat.shininess || 1);
color = vAdd(color, vMul(light.color, spec * (mat.specular || 0) * intensity * 40));
}
// 环境光:取场景天空平均亮度,避免阴影区全黑
color = vAdd(color, vMulV(mat.albedo, vec3(0.08, 0.08, 0.1)));
// 递归反射(金属材质)
if (mat.type === 'metal' && depth > 0) {
var reflDir = vReflect(ray.direction, normal);
// fuzz 模糊:沿 Halton 序列偏移反射方向(确定性)
if (mat.fuzz > 0) {
var jx = (halton(depth * 7 + stats.rays % 97, 2) - 0.5) * mat.fuzz;
var jy = (halton(depth * 13 + stats.rays % 89, 3) - 0.5) * mat.fuzz;
reflDir = vNorm(vAdd(reflDir, vec3(jx, jy, jx)));
}
var reflRay = makeRay(vAdd(hit.point, vMul(normal, 1e-3)), reflDir);
var reflColor = traceRay(scene, reflRay, depth - 1, stats);
color = vAdd(color, vMulV(vMul(reflColor, mat.reflectivity), mat.albedo));
}
return color;
}
预设场景:
引擎内置三个精心设计的场景,每个场景有独立的相机参数和物体排布:
| 场景 | 特点 |
|---|---|
| 经典三球 | 漫反射地面 + 漫反射红球 + 金属银球 + 金属金球 + 蓝色小球,双光源 |
| 金属矩阵 | 3×3 Halton 抖动彩色金属球 + 镜面地面,三光源暗色调 |
| 光池夜景 | 中央自发光球照明 + 环绕彩色漫反射球,暗环境氛围 |
3.4 渐进式渲染调度(renderer.js)
renderer.js 负责参数面板交互和渐进式渲染调度。核心设计思路是:每批渲染 batchSize 行像素后通过 setTimeout(0) 让出主线程,保持 UI 实时可响应,避免长时间阻塞导致界面冻结。
渲染启动与调度:
function startRender() {
if (state.rendering) return;
var cam = SCENE_CAMERA[state.scene];
var scene = SCENES[state.scene].build();
var camera = makeCamera(
vec3(cam.pos[0], cam.pos[1], cam.pos[2]),
vec3(cam.target[0], cam.target[1], cam.target[2]),
state.fov,
state.width / state.height
);
var renderer = makeRenderer(scene, camera, state.width, state.height, state.samples, state.maxDepth);
job = {
renderer: renderer,
imgData: ctx.createImageData(state.width, state.height),
row: 0,
startTime: Date.now(),
timer: null,
cancelled: false
};
state.rendering = true;
showEmpty(false);
setControlsEnabled(false);
progressBar.classList.remove('done');
updateProgress();
scheduleBatch();
}
// 渐进式调度:每批渲染 batchSize 行后让出主线程
function scheduleBatch() {
job.timer = setTimeout(renderBatch, 0);
}
function renderBatch() {
if (!job || job.cancelled) return;
var w = job.renderer.width;
var rows = Math.min(state.batchSize, job.renderer.height - job.row);
for (var i = 0; i < rows; i++) {
var py = job.row + i;
var row = renderRow(job.renderer, py);
for (var px = 0; px < w; px++) {
var idx = (py * w + px) * 4;
job.imgData.data[idx] = row[px * 3];
job.imgData.data[idx + 1] = row[px * 3 + 1];
job.imgData.data[idx + 2] = row[px * 3 + 2];
job.imgData.data[idx + 3] = 255;
}
}
job.row += rows;
ctx.putImageData(job.imgData, 0, 0);
updateProgress();
if (job.row < job.renderer.height) {
scheduleBatch();
} else {
finishRender();
}
}
渐进式调度的关键设计:
- setTimeout(renderBatch, 0):每批渲染完后放入宏任务队列,让浏览器有机会处理 UI 事件
- batchSize 可调(1~24 行):小值 UI 更流畅但渲染慢,大值渲染快但 UI 可能卡顿
- job.cancelled 标志:支持随时停止渲染,停止后显示已完成行数
- 场景/参数变更时自动停止上一次渲染并重置 UI
3.5 PNG 图像导出
渲染完成后,通过 Canvas toDataURL 获取 Base64 编码数据,再经 IPC 传给主进程写入本地文件:
function exportImage() {
if (state.rendering || !canvas) return;
var dataUrl = canvas.toDataURL('image/png');
ipcRenderer.invoke('image:saveDialog').then(function(result) {
if (result && !result.canceled && result.filePath) {
ipcRenderer.invoke('image:save', result.filePath, dataUrl).then(function(saveResult) {
if (saveResult.success) {
progressText.textContent = '已导出: ' + result.filePath + '(' +
(saveResult.size / 1024).toFixed(1) + ' KB)';
} else {
progressText.textContent = '导出失败: ' + saveResult.error;
}
});
}
});
}
导出流程: Canvas → toDataURL(‘image/png’) → IPC image:saveDialog(弹出保存对话框)→ IPC image:save(Base64 解码 + fs.writeFileSync 写入文件)。
3.6 Catppuccin Mocha 深色主题
整个 UI 采用 Catppuccin Mocha 配色方案,通过 CSS 变量统一管理 20+ 颜色令牌:
:root {
--base: #1e1e2e;
--mantle: #181825;
--crust: #11111b;
--surface0: #313244;
--text: #cdd6f4;
--blue: #89b4fa;
--green: #a6e3a1;
--red: #f38ba8;
--mauve: #cba6f7;
/* ... */
}
主题覆盖了工具栏、参数面板、画布区域、进度条、状态栏、关于弹窗等所有 UI 组件,确保视觉一致性。
四、鸿蒙稳定性适配
4.1 禁用默认菜单栏
问题现象: 应用启动后顶部出现系统菜单栏,与自定义工具栏重叠。
根因分析: Electron 默认会创建包含 File/Edit/View 等菜单项的应用菜单栏。在鸿蒙平台上,这个菜单栏不仅多余,还可能干扰自定义 UI 的布局。
解决方案: 在 createWindow 中调用 Menu.setApplicationMenu(null) 移除默认菜单栏,所有交互通过自定义工具栏实现。
Menu.setApplicationMenu(null);
4.2 自定义关于弹窗
问题现象: 点击关于按钮时如果使用原生 alert/dialog,应用崩溃。
根因分析: 原生 alert() 函数在鸿蒙平台上会创建模态 SubWindow,触发 XComponent 创建超时崩溃。
解决方案: 使用纯 div + overlay 实现关于弹窗,通过 CSS class 切换控制显示隐藏:
<div class="about-overlay" id="aboutOverlay">
<div class="about-dialog">
<div class="about-header">
<span class="about-title">PyRayTrace</span>
<button class="about-close" id="btnAboutClose">×</button>
</div>
<div class="about-body">
<div class="about-version">v1.0.0</div>
<div class="about-desc">纯 JavaScript 光线追踪渲染器,基于 Electron 壳方案适配 HarmonyOS</div>
<!-- 开源声明 + 免责声明 -->
</div>
</div>
</div>
var aboutOverlay = document.getElementById('aboutOverlay');
document.getElementById('btnAbout').addEventListener('click', function() {
aboutOverlay.classList.add('visible');
});
document.getElementById('btnAboutClose').addEventListener('click', function() {
aboutOverlay.classList.remove('visible');
});
aboutOverlay.addEventListener('click', function(e) {
if (e.target === aboutOverlay) aboutOverlay.classList.remove('visible');
});
4.3 鸿蒙三防窗口配置
主进程窗口创建遵循鸿蒙 Electron 壳方案的标准化配置:
| 配置项 | 值 | 作用 |
|---|---|---|
| disableHardwareAcceleration | true | 防止 GPU 渲染白屏 |
| frame | true | 使用系统原生标题栏 |
| transparent | false | 禁用透明,防止渲染异常 |
| resizable | true | 允许窗口缩放 |
| try-catch | 包裹 createWindow | 防止单点故障导致进程退出 |
五、构建与部署



5.1 本地调试
在 electron-apps/PyRayTrace 目录下直接运行:
cd electron-apps/PyRayTrace
npm install electron --save-dev
npm start
5.2 部署到鸿蒙壳工程
将开发目录全量同步到 web_engine 部署目录:
# 清空部署目录
Remove-Item "web_engine/src/main/resources/resfile/resources/app/*" -Recurse -Force
# 复制应用文件
Copy-Item "electron-apps/PyRayTrace/*" "web_engine/src/main/resources/resfile/resources/app/" -Recurse -Force
5.3 构建 HAP 包
在 DevEco Studio 中打开项目,选择 Build → Build Hap(s)/APP(s) → Build Hap(s),即可生成可安装的 HAP 包。
六、常见问题与解决方案
Q1:改了 base/string.json 的应用名,设备上仍然显示旧名
问题现象: 将 electron/src/main/resources/base/element/string.json 中的 EntryAbility_label 改为 PyRayTrace 光线追踪渲染器后,重新构建安装到设备上,桌面图标下方仍然显示 Electron 壳工程的默认名称。
根因分析: 鸿蒙资源系统存在本地化目录优先级覆盖机制——en_US/element/string.json 和 zh_CN/element/string.json 中与 base 同名的 key 会覆盖 base 的值。壳工程模板中 en_US 和 zh_CN 的 EntryAbility_label 仍然是 Electron,设备根据系统语言匹配到对应本地化目录后,base 的修改被完全屏蔽。
解决: 必须同时修改三套资源文件,确保所有语言环境下名称一致:
// electron/src/main/resources/base/element/string.json
{
"name": "EntryAbility_label",
"value": "PyRayTrace 光线追踪渲染器"
}
// electron/src/main/resources/en_US/element/string.json
{
"name": "EntryAbility_label",
"value": "PyRayTrace 光线追踪渲染器"
}
// electron/src/main/resources/zh_CN/element/string.json
{
"name": "EntryAbility_label",
"value": "PyRayTrace 光线追踪渲染器"
}
教训: 鸿蒙资源覆盖规则是 locale目录 > base目录,修改应用名时必须全局 grep 所有 element/string.json 文件,不能只改 base。
Q2:构建签名报错 ERROR: 11014001 key alias not found
问题现象: 在 DevEco Studio 中执行 Build → Build Hap(s),SignHap 阶段报错 ERROR: 11014001,提示签名密钥别名找不到。
根因分析: build-profile.json5 中 signingConfigs.default.material.keyAlias 填写的值与 .p12 签名文件内实际存储的别名不一致。鸿蒙构建系统使用 DevEco 内置 JBR(JetBrains Runtime)解析密钥库,如果系统 JDK 版本与密钥库格式不兼容,也会导致解析失败。
解决: 使用 DevEco 内置 JBR 的 keytool 查看 p12 内真实别名:
# DevEco Studio 内置 JBR 路径(Windows)
"%DEVECO_JBR%\jbr\bin\keytool" -list -keystore "PyRayTrace.p12" -storepass 你的密码
查看输出中的别名名称(Alias Name),然后填入 build-profile.json5:
// build-profile.json5(节选)
"signingConfigs": [
{
"name": "default",
"type": "HarmonyOS",
"material": {
"storeFile": "C:/path/to/PyRayTrace.p12",
"keyAlias": "PyRayTrace1", // ← 必须与 p12 内真实别名完全一致
"signAlg": "SHA256withECDSA",
"profile": "C:/path/to/PyRayTraceRelease.p7b",
"certpath": "C:/path/to/PyRayTrace.cer"
}
}
]
也可以在 DevEco Studio 中通过 File → Project Structure → Signing Configs 界面查看和选择正确的别名。
Q3:AGC 审核驳回——快捷方式(shortcuts)非应用自身功能
问题现象: 提交 AGC 审核后收到驳回意见:module.json5 中声明了 ohos.ability.shortcuts 快捷方式,但该快捷方式并非应用自身功能入口,不符合上架规范。
根因分析: Electron 壳工程模板默认在 module.json5 的 metadata 中声明了 ohos.ability.shortcuts,并关联 shortcuts_config.json 配置文件。这些快捷方式指向的是壳工程的通用能力(如打开新窗口),而非 PyRayTrace 应用自身的功能入口,AGC 审核会将其识别为模板残留。
解决: 删除 module.json5 中的 shortcuts 元数据和对应的配置文件:
// 修改前:electron/src/main/module.json5
"metadata": [
{
"name": "ohos.ability.shortcuts",
"value": "$profile:shortcuts_config"
},
{
"name": "client_id",
"value": ""
}
]
// 修改后:移除 shortcuts 相关 metadata
"metadata": [
{
"name": "client_id",
"value": ""
}
]
同时删除 electron/src/main/resources/base/profile/shortcuts_config.json 文件。
Q4:修改了 electron-apps 下的代码,但 HAP 安装后没有生效
问题现象: 在 electron-apps/PyRayTrace/ 目录下修改了 renderer.js、index.html 等文件,重新构建 HAP 安装到设备后,应用行为没有任何变化。
根因分析: 鸿蒙壳工程的实际运行文件并不在 electron-apps/ 目录下,而是从 web_engine/src/main/resources/resfile/resources/app/ 部署目录加载。electron-apps/ 只是开发目录,修改后必须手动同步到部署目录才能进入签名包。这是一个非常容易忽略的双份文件问题。
解决: 每次修改 electron-apps/ 下的业务代码后,执行同步命令:
# 清空部署目录
Remove-Item "web_engine/src/main/resources/resfile/resources/app/*" -Recurse -Force
# 复制应用文件到部署目录
Copy-Item "electron-apps/PyRayTrace/*" "web_engine/src/main/resources/resfile/resources/app/" -Recurse -Force
验证方法: 同步后用文件哈希对比确认一致性:
# 对比源目录与部署目录的关键文件
$files = @("index.html","renderer.js","main.js","raytrace-engine.js","styles/raytrace.css")
foreach ($f in $files) {
$src = Get-FileHash "electron-apps/PyRayTrace/$f" -Algorithm MD5
$dst = Get-FileHash "web_engine/src/main/resources/resfile/resources/app/$f" -Algorithm MD5
if ($src.Hash -eq $dst.Hash) { Write-Host "[OK] $f" }
else { Write-Host "[MISMATCH] $f" -ForegroundColor Red }
}
Q5:AGC 审核驳回——requestPermissions 声明了受限 ACL 权限
问题现象: AGC 审核驳回,指出 web_engine/src/main/module.json5 中声明了 ohos.permission.FILE_ACCESS_PERSIST、ohos.permission.READ_WRITE_DESKTOP_DIRECTORY 等受限 ACL 权限,但应用隐私政策中未声明对应用途,且应用实际不使用这些能力。
根因分析: Electron 壳工程模板默认声明了大量系统权限,其中部分为受限 ACL 权限(需要额外审批)和 user_grant 权限(需要用户运行时授权)。PyRayTrace 作为纯渲染器,文件操作通过系统文件选择器完成(临时授权),不需要持久化文件访问、桌面目录读写、相机、麦克风、蓝牙等权限。
解决: 精简 web_engine/src/main/module.json5 的权限声明,只保留应用实际使用的 14 项系统权限:
// web_engine/src/main/module.json5(节选)
"requestPermissions": [
{ "name": "ohos.permission.INTERNET" },
{ "name": "ohos.permission.GET_NETWORK_INFO" },
{ "name": "ohos.permission.ACCESS_CERT_MANAGER" },
{ "name": "ohos.permission.RUNNING_LOCK" },
{ "name": "ohos.permission.PRINT" },
{ "name": "ohos.permission.PREPARE_APP_TERMINATE" },
{ "name": "ohos.permission.ACCESS_BIOMETRIC" },
{ "name": "ohos.permission.PRIVACY_WINDOW" },
{ "name": "ohos.permission.WINDOW_TOPMOST" },
{ "name": "ohos.permission.GYROSCOPE" },
{ "name": "ohos.permission.ACCELEROMETER" },
{ "name": "ohos.permission.SET_ABILITY_INSTANCE_INFO" },
{ "name": "ohos.permission.GET_FILE_ICON" },
{ "name": "ohos.permission.LOCK_WINDOW_CURSOR" }
]
移除的权限清单:
| 移除权限 | 类型 | 移除原因 |
|---|---|---|
| FILE_ACCESS_PERSIST | ACL 受限 | 文件操作通过系统选择器临时授权 |
| READ_WRITE_DESKTOP_DIRECTORY | ACL 受限 | 不直接访问桌面目录 |
| READ_WRITE_DOCUMENTS_DIRECTORY | ACL 受限 | 不直接访问文档目录 |
| READ_WRITE_DOWNLOAD_DIRECTORY | ACL 受限 | 不直接访问下载目录 |
| READ_PASTEBOARD | ACL 受限 | 不读取剪贴板 |
| LOCATION / CAMERA / MICROPHONE 等 | user_grant | 渲染器不需要位置、相机、麦克风 |
Q6:渲染过程中界面冻结无响应
问题现象: 点击渲染按钮后,画面开始逐行绘制,但进度条不更新、停止按钮点击无反应、滑块拖动卡顿,整个界面像是卡死。
根因分析: 光线追踪是计算密集型任务。如果一次性渲染所有像素行,主线程会被 renderRow 的密集循环长时间阻塞(640×480 分辨率下可能需要数十秒),导致 UI 事件(进度条更新、按钮点击、滑块 input)无法被处理。
解决: 采用渐进式渲染调度,每批渲染 batchSize 行后通过 setTimeout(0) 让出主线程:
// 渐进式调度:每批渲染 batchSize 行后让出主线程,保持 UI 可响应
function scheduleBatch() {
job.timer = setTimeout(renderBatch, 0);
}
function renderBatch() {
if (!job || job.cancelled) return;
var w = job.renderer.width;
var rows = Math.min(state.batchSize, job.renderer.height - job.row);
for (var i = 0; i < rows; i++) {
var py = job.row + i;
var row = renderRow(job.renderer, py);
for (var px = 0; px < w; px++) {
var idx = (py * w + px) * 4;
job.imgData.data[idx] = row[px * 3];
job.imgData.data[idx + 1] = row[px * 3 + 1];
job.imgData.data[idx + 2] = row[px * 3 + 2];
job.imgData.data[idx + 3] = 255;
}
}
job.row += rows;
ctx.putImageData(job.imgData, 0, 0);
updateProgress();
if (job.row < job.renderer.height) {
scheduleBatch(); // 让出主线程后继续下一批
} else {
finishRender();
}
}
batchSize 通过滑块暴露给用户调节(1~24 行),在渲染速度与 UI 流畅度之间取得平衡。
Q7:导出按钮点击无反应
问题现象: 渲染完成后,点击导出按钮没有任何反应,无法弹出文件保存对话框。
根因分析: setControlsEnabled 函数中对导出按钮的 disabled 属性赋值逻辑写反了。渲染完成调用 setControlsEnabled(true) 时,btnExport.disabled = true 反而把按钮禁用了:
// 错误代码
function setControlsEnabled(enabled) {
document.getElementById('btnExport').disabled = enabled; // ← 逻辑反了
}
解决: 将赋值改为 !enabled,使 enabled=true 时按钮可用:
// 修复后
function setControlsEnabled(enabled) {
document.getElementById('btnRender').disabled = !enabled;
document.getElementById('btnStop').disabled = enabled;
document.getElementById('btnExport').disabled = !enabled; // ← 修正逻辑
}
这个问题在标准 Electron 环境中同样会出现,属于逻辑笔误,但在渲染器场景下特别容易被忽略——因为开发者往往关注渲染引擎本身的正确性,而忽略了 UI 状态机的细节。
七、总结
本文完整记录了在鸿蒙 PC 平台上适配 PyRayTrace 光线追踪渲染器的全过程。通过 Electron 壳方案,我们实现了一个功能完整的纯 JavaScript 光线追踪渲染器,核心特性包括:
| 特性 | 实现方案 |
|---|---|
| 光线追踪引擎 | 纯 JavaScript Whitted 风格:直接光照 + 阴影 + 递归反射 |
| 材质系统 | Lambert 漫反射 + Metal 金属反射(fuzz 模糊)+ Emissive 自发光 |
| 抗锯齿 | Halton(2,3) 低差异序列亚像素采样,1/2/4/9 可选 |
| 渐进式渲染 | setTimeout 分批调度,每批 1~24 行可调,UI 始终可响应 |
| 预设场景 | 经典三球 / 金属矩阵 / 光池夜景,各有独立相机参数 |
| PNG 导出 | Canvas toDataURL + IPC + fs.writeFileSync 真实文件写入 |
| 实时统计 | 射线总数 / 射线每秒 / 渲染耗时 / 进度百分比 |
| 鸿蒙稳定 | 禁用默认菜单 + 自定义关于弹窗 + 三防窗口配置 |
适配过程中最关键的三个点:
- 渐进式渲染调度——光线追踪计算密集,必须分批让出主线程保持 UI 可交互,这是渲染类应用在鸿蒙上的核心挑战
- Halton 低差异序列——替代随机数用于抗锯齿和 fuzz 模糊,保证确定性、可复现、均匀分布
- 自定义弹窗替代原生 dialog——鸿蒙 ArkWeb 引擎上原生弹窗触发 SubWindow 崩溃,必须用纯 div 实现
这些问题在标准 Electron 环境中不会出现或表现不同,是鸿蒙 ArkWeb 引擎特有的兼容性挑战。通过系统性的排查和修复,最终实现了稳定可用的鸿蒙版纯 JavaScript 光线追踪渲染器。
更多推荐




所有评论(0)