HarmonyOS 鸿蒙 MetalFx 原生 GPU 金属特效实战 —— 打通 ArkUI + XComponent + OpenGL ES 3 渲染链路
一、前言:为什么偏偏是 MetalFx 要上 Native?
先讲清楚动机。上一系列(GrokBot、ThinkingOrbs、FlowAvatar、GradientSpin)全是「纯 ArkTS + Canvas + DisplaySync」,跑得很好。但 BorderBeam(E023)暴露了一个能力天花板:
filter: blur(8~22px)这种「一次糊整层」的 GPU 操作,无法用「多层 soft fill」在 CPU/Canvas 侧廉价近似。
而 metal-fx(Jakubantalik/metal-fx,MIT)的 Plasma 金属环,本质上就是一个逐像素的 fragment shader:snoise 噪波 + fbm 分形 + 调色板 + 圆角环 SDF 遮罩。这种东西天生就该在 GPU 上跑,而不是在 Canvas 里用大量 fill 拼。
所以 E024 的研究问题很直接:
能否在鸿蒙 ArkUI 里,用 XComponent 的 TEXTURE 模式提供一个「透明 GPU 叠层」,把 WebGL 的 Plasma shader 原样搬过来,包装成内容 wrapper,让 blur / 噪波 / SDF 全部由 GPU 完成?
答案方向是肯定的(编译链已打通),但有两个「真机闸门」待验证(见 §七)。
1.1 一张图看懂调用链
@LocalBuilder content ← 用户真实内容(按钮、圆形卡……)
→ Stack { content; XComponent(TEXTURE, hit-test none) }
→ onLoad 拿到 Native context
→ C++ instance manager(按 XComponent ID 隔离实例)
→ EGL RGBA8 + OpenGL ES 3
→ Plasma shader × rounded-rect SDF ring
ArkTS 负责 preset、主题、布局、生命周期;C++ 负责 EGL/OpenGL ES 3 和逐实例 Surface。
二、ArkTS 侧:一个透明的 Native 叠层包裹器
2.1 公共 API
MetalFx({
content: this.ButtonBody, // @BuilderParam,用户真实内容
variant: MetalFxVariant.Button, // Button | Circle
preset: MetalFxPreset.Chromatic, // Chromatic | Silver | Gold
theme: MetalFxTheme.Auto, // Auto | Dark | Light
darkSurface: true, // Auto 主题下由宿主注入,不读 AppStorage
strength: 1, // 只改最终 alpha
paused: false, // 冻结当前时间,不清空最后一帧
metalRadius: -1, // -1 → preset 默认(按钮=高的一半 / 圆环=短边一半)
ringWidth: -1, // -1 → 按钮 1vp / 圆环 2vp
shaderScale: -1 // -1 → 按钮 1.6 / 圆环 1.3
})
注意保留名问题又出现了:radius / brightness 等是 ArkUI 保留 attribute,所以公开 API 用 metalRadius / ringWidth / shaderScale(和 FlowAvatar 的 avatarSize、BorderBeam 的 beamRadius 一脉相承)。
2.2 XComponent 是「透明叠层」,不是普通画布
这是整个架构的关键。build() 里的结构是:
build() {
Stack({ alignContent: Alignment.TopStart }) {
Column() {
this.content() // 1. 用户真实内容
}
.onAreaChange((_o, area) => { /* 测量内容尺寸 */ })
XComponent({
id: this.xComponentId,
type: XComponentType.TEXTURE, // 关键:TEXTURE 类型
libraryname: 'metalfx' // 关联 .so 模块名
})
.width(this.contentWidth)
.height(this.contentHeight)
.position({ x: 0, y: 0 })
.backgroundColor('#00000000') // 透明背景
.hitTestBehavior(HitTestMode.None) // 不拦截点击
.onLoad((context?: object): void => {
if (context !== undefined) {
this.nativeContext = context as MetalFxNativeContext;
this.syncNative();
this.reconcileActivity();
}
})
.onDestroy((): void => { this.nativeContext = undefined; })
}
.clip(true)
.onVisibleAreaChange([0.0, 0.01, 1.0], (visible) => {
this.visible = visible;
this.reconcileActivity();
})
}
四个要点:
-
XComponentType.TEXTURE—— 提供一个原生渲染的纹理层,可做到中心透明(RGBA8),正好用来叠在内容上的「金属环」,环外透明、环内透明,只有环本身有颜色。 -
libraryname: 'metalfx'—— 关联编译出的libmetalfx.so。ArkTS 不用import那个.so,而是通过onLoad的context拿到 Native 暴露的方法。 -
hitTestBehavior: HitTestMode.None—— 让点击、按压、无障碍完全透传给下面的内容节点。 -
定位叠层 —— XComponent 与 content 同尺寸、
position(0,0)覆盖在上方。
2.3 context 的三个方法
onLoad 返回的 context 就是 C++ 通过 NAPI 挂到 XComponent 上的方法集合,TS 侧用 interface 描述:
export interface MetalFxNativeContext {
updateConfig(config: MetalFxNativeConfig): void; // 推配置
setActive(active: boolean): void; // 开关帧回调
requestRender(): void; // 请求画一帧
}
syncNative 会把当前配置序列化成一个扁平对象(因为要过 NAPI 边界,不能传 class),再调 updateConfig + requestRender:
private syncNative(): void {
if (this.nativeContext === undefined) return;
this.nativeContext.updateConfig(this.nativeConfig());
this.nativeContext.requestRender();
}
三、vp → px:跨语言边界的第一道坎
ArkTS 的布局单位是 vp,但 Shader / EGL Surface 活在物理像素 px 世界。圆角半径、环宽必须换算,否则不同 DPR 屏上环会粗细不一、圆角会错位。
MetalFxCore.ets 的 buildMetalFxNativeConfig 做这件事:
// 拿到 1vp 对应多少像素
private pxScale(): number {
return this.getUIContext().vp2px(1);
}
// 圆角:vp → px
config.radiusPx = metalFxResolveRadius(variant, explicitRadius, widthVp, heightVp) * pxScale;
// 环宽:clamp 后 vp → px
const ringVp = explicitRing >= 0 ? explicitRing : metalFxDefaultRingWidth(variant);
config.ringPx = clamp(ringVp, 0.5, Math.max(0.5, Math.min(widthVp, heightVp) / 2)) * pxScale;
metalFxResolveRadius 的默认逻辑也值得注意:
-
Button 默认圆角 = 高度的一半(胶囊按钮);
-
Circle 默认圆角 = 短边的一半(正圆)。
三个 preset 的颜色用 metalFxHexToRgb 把 #RRGGBB 提前转成 [0,1] 的浮点三元组,一次 flatten 进一个 Array<number>,再交给 NAPI 解析成 std::array<float, 15>(5 个颜色 × RGB)。
四、NAPI + XComponent:C++ 如何「接管」一个组件
4.1 模块注册
napi_init.cpp 用一个 constructor 属性在 .so 加载时自动注册模块:
static napi_module metalFxModule = {
.nm_version = 1,
.nm_register_func = Init,
.nm_modname = "metalfx", // 对应 ArkTS 里的 libraryname: 'metalfx'
// ...
};
extern "C" __attribute__((constructor)) void RegisterMetalFxModule() {
napi_module_register(&metalFxModule);
}
Init 调用 MetalFxManager::Instance().Export(env, exports),后者是关键。
4.2 Export:从 XComponent 拿到 Native 指针
void MetalFxManager::Export(napi_env env, napi_value exports) {
// 1. 从 exports 里取出 OH_NATIVE_XCOMPONENT_OBJ(XComponent 注入的隐藏属性)
napi_value exportInstance = nullptr;
napi_get_named_property(env, exports, OH_NATIVE_XCOMPONENT_OBJ, &exportInstance);
// 2. unwrap 出真正的 OH_NativeXComponent* 指针
OH_NativeXComponent* component = nullptr;
napi_unwrap(env, exportInstance, reinterpret_cast<void**>(&component));
// 3. 用组件 ID 隔离实例(一个 XComponent 一个 Renderer)
const std::string id = ComponentId(component);
// ... 查找或创建 MetalFxRenderer,绑定并注册回调
// 4. 把三个方法挂到 exports 上,ArkTS 侧 context 才能调用
napi_property_descriptor descriptors[] = {
{"updateConfig", nullptr, NapiUpdateConfig, nullptr, nullptr, nullptr, napi_default, nullptr},
{"setActive", nullptr, NapiSetActive, nullptr, nullptr, nullptr, napi_default, nullptr},
{"requestRender",nullptr, NapiRequestRender,nullptr, nullptr, nullptr, napi_default, nullptr}
};
napi_define_properties(env, exports, 3, descriptors);
}
这段是整个「跨语言桥」的核心:XComponent 通过 onLoad 把 exports 传给 ArkTS,C在 Export 里往这个 exports 上挂方法,同时从隐藏属性 OH_NATIVE_XCOMPONENT_OBJ 里 unwrap 出原生指针,再用 ComponentId 把「这个 XComponent」映射到「唯一的 C Renderer 实例」。
4.3 参数解析:NAPI 的繁琐但必要的防御
每个方法都要从 thisArg 反推出是哪个组件、再校验参数:
napi_value MetalFxManager::NapiUpdateConfig(napi_env env, napi_callback_info info) {
size_t argc = 1;
napi_value args[1] = {nullptr};
napi_value thisArg = nullptr;
napi_get_cb_info(env, info, &argc, args, &thisArg, nullptr);
auto* renderer = Instance().RendererFromThis(env, thisArg); // 反查实例
// ... 逐个读 colors/alphas/speed/... 共 16 个字段,缺失即 throw
renderer->UpdateConfig(config);
}
RendererFromThis 的逻辑很有意思:它又从 thisArg 里取一次 OH_NATIVE_XCOMPONENT_OBJ、unwrap、再查表——因为 NAPI 方法被调用时,thisArg 指向的就是 onLoad 传出去的那个 context 对象,从它身上能找回原生组件。
五、C++ 渲染器:EGL + OpenGL ES 3 的完整生命周期
5.1 共享 EGL Display:引用计数防「跨翻译单元析构顺序」问题
一个容易被忽略的坑:多个 MetalFxRenderer 实例共享同一个 EGLDisplay。如果每个都 eglInitialize / eglTerminate,就会在 library 卸载时因析构顺序不一致而崩溃。MetalFx 用进程级引用计数解决:
std::mutex sharedDisplayMutex;
EGLDisplay sharedDisplay = EGL_NO_DISPLAY;
uint32_t sharedDisplayUsers = 0;
EGLDisplay AcquireDisplay() {
std::lock_guard<std::mutex> lock(sharedDisplayMutex);
if (sharedDisplay == EGL_NO_DISPLAY) {
sharedDisplay = eglGetDisplay(EGL_DEFAULT_DISPLAY);
if (sharedDisplay == EGL_NO_DISPLAY || eglInitialize(sharedDisplay, nullptr, nullptr) != EGL_TRUE) {
return EGL_NO_DISPLAY;
}
}
sharedDisplayUsers++; // 计数 +1
return sharedDisplay;
}
void ReleaseDisplay(EGLDisplay display) {
// 计数 -1,归零才 eglTerminate
}
5.2 EGL 初始化:RGBA8 是关键
bool MetalFxRenderer::InitEgl(void* window) {
display_ = AcquireDisplay();
eglBindAPI(EGL_OPENGL_ES_API);
const EGLint configAttributes[] = {
EGL_SURFACE_TYPE, EGL_WINDOW_BIT,
EGL_RENDERABLE_TYPE, EGL_OPENGL_ES3_BIT,
EGL_RED_SIZE, 8,
EGL_GREEN_SIZE, 8,
EGL_BLUE_SIZE, 8,
EGL_ALPHA_SIZE, 8, // ← 8bit alpha,透明叠加的根基
EGL_NONE
};
EGLConfig config = nullptr;
eglChooseConfig(display_, configAttributes, &config, 1, &count);
const EGLint ctxAttrs[] = {EGL_CONTEXT_CLIENT_VERSION, 3, EGL_NONE}; // OpenGL ES 3.0
context_ = eglCreateContext(display_, config, EGL_NO_CONTEXT, ctxAttrs);
surface_ = eglCreateWindowSurface(display_, config, nativeWindow, nullptr);
return eglMakeCurrent(display_, surface_, surface_, context_) == EGL_TRUE;
}
注意每个实例拥有独立的 EGLSurfurface、EGLContext、program,E024 没有提前做跨 Surface 的 GL object sharing——这是有意的简化边界。
5.3 生命周期回调:注册 + 反注册 frame callback
void MetalFxRenderer::RegisterCallbacks() {
callbacks_.OnSurfaceCreated = OnSurfaceCreated;
callbacks_.OnSurfaceChanged = OnSurfaceChanged;
callbacks_.OnSurfaceDestroyed = OnSurfaceDestroyed;
callbacks_.DispatchTouchEvent = OnTouchEvent; // 空实现,触摸全透传
OH_NativeXComponent_RegisterCallback(component_, &callbacks_);
}
OnSurfaceDestroyed 时移除实例:
void OnSurfaceDestroyed(OH_NativeXComponent* component, void*) {
const std::string id = ComponentId(component);
auto* renderer = MetalFxManager::Instance().Find(component);
if (renderer != nullptr) renderer->SurfaceDestroyed();
if (!id.empty()) MetalFxManager::Instance().Remove(id);
}
5.4 帧时钟:按需注册 / 反注册,静止就停
这是和 BorderBeam、GrokBot 一脉相承的「按需启停」思路,只不过搬到了 Native 侧:
void MetalFxRenderer::ReconcileFrameCallback() {
const bool shouldRun = active_ && surfaceReady_;
if (shouldRun && !frameRegistered_) {
OH_NativeXComponent_ExpectedRateRange range{30, 60, 60}; // min 30, max 60
OH_NativeXComponent_SetExpectedFrameRateRange(component_, &range);
if (OH_NativeXComponent_RegisterOnFrameCallback(component_, OnFrameCallback) == SUCCESS) {
frameRegistered_ = true;
}
} else if (!shouldRun && frameRegistered_) {
OH_NativeXComponent_UnregisterOnFrameCallback(component_);
frameRegistered_ = false;
}
}
OnFrame 回调里用 timestamp 差值累加时间,clamp 到 0.1 秒防止后台恢复时时间突跳:
void MetalFxRenderer::OnFrame(uint64_t timestamp) {
if (!active_ || !surfaceReady_) return;
if (lastFrameTimestamp_ != 0 && timestamp > lastFrameTimestamp_) {
const double delta = (timestamp - lastFrameTimestamp_) / 1'000'000'000.0;
accumulatedTime_ += std::min(delta, 0.1); // 防突跳
}
lastFrameTimestamp_ = timestamp;
Render(accumulatedTime_);
}
SetActive(false) 时(暂停/离屏/消失)会反注册 frame callback,同时仍 Render 一帧保底(Pause 语义是「冻结时间、不清空最后一帧」)。
六、GLSL:把 WebGL 的 Plasma 原样搬进 GLES 3
这是 MetalFx 的「灵魂」。片元着色器从 metal-fx v1.0.4(pin be1bf89)移植,包含三大块。
6.1 snoise + fbm:Simplex 噪波与分形叠加
vec3 mod289(vec3 x) { return x - floor(x * (1.0 / 289.0)) * 289.0; }
vec3 permute(vec3 x) { return mod289((x * 34.0 + 1.0) * x); }
float snoise(vec2 v) { /* 标准 2D Simplex 噪波,约 20 行 */ }
float fbm(vec2 p, float oct) {
float value = 0.0;
float amplitude = 0.5;
for (int i = 0; i < 7; i++) {
if (i >= int(oct)) break;
value += amplitude * snoise(p);
p *= 2.0;
amplitude *= 0.5;
}
return value;
}
snoise 是标准的 Simplex 噪声实现,fbm 是分形布朗运动(叠多倍频噪声),nfbm 用 complexity 参数控制 octave 数。
6.2 palette:五色加权调色板
vec3 palette(float t) {
t = clamp(t, 0.0, 1.0);
t = t * t * (3.0 - 2.0 * t); // smoothstep 平滑
float k = 64.0;
float w1 = u_alphas[0] * exp(-k * t * t);
float w2 = u_alphas[1] * exp(-k * (t - 0.25) * (t - 0.25));
// ... w3 / w4 / w5
float total = w1 + w2 + w3 + w4 + w5 + 0.0001;
return (u_colors[0]*w1 + u_colors[1]*w2 + ... + u_colors[4]*w5) / total;
}
用 5 个「高斯权重」在颜色上插值,就是 Chromatic/Silver/Gold 三种金属质感的来源。
6.3 rounded-rect SDF:替代 Web 的 destination-out 掏孔
这是 E024 相比 BorderBeam 的最大优化——BorderBeam 用 Canvas 的 destination-out 掏孔(CPU 侧 soft fill),MetalFx 把「圆角环」变成一个**有向距离场(SDF)**在 shader 里算:
float roundedRectSdf(vec2 point, vec2 halfSize, float radius) {
float safeRadius = clamp(radius, 0.0, min(halfSize.x, halfSize.y));
vec2 q = abs(point) - (halfSize - vec2(safeRadius));
return length(max(q, 0.0)) + min(max(q.x, q.y), 0.0) - safeRadius;
}
float ringMask(vec2 pixel) {
vec2 halfSize = max(vec2(1.0), u_resolution * 0.5 - vec2(0.5));
vec2 centered = pixel - u_resolution * 0.5;
float outerDistance = roundedRectSdf(centered, halfSize, u_radiusPx); // 外环
float ring = clamp(u_ringPx, 0.5, min(halfSize.x, halfSize.y));
vec2 innerHalf = max(vec2(0.5), halfSize - vec2(ring));
float innerDistance = roundedRectSdf(centered, innerHalf, max(0.0, u_radiusPx - ring)); // 内孔
float aa = max(0.75, fwidth(outerDistance)); // 基于屏幕导数的抗锯齿
float outerAlpha = 1.0 - smoothstep(-aa, aa, outerDistance);
float innerAlpha = 1.0 - smoothstep(-aa, aa, innerDistance);
return clamp(outerAlpha - innerAlpha, 0.0, 1.0); // 外环 - 内孔 = 环
}
outerAlpha - innerAlpha 就是「环」——外圆角矩形减去内缩的内圆角矩形,剩下的就是环带。这等价于 BorderBeam 里 destination-out + destination-in 的两步 punch,但在 GPU 上每像素算一次,没有 Canvas 复制、没有 CPU soft fill。
而且它自带抗锯齿:用 fwidth() 取屏幕空间导数做 smoothstep 的宽度,环边缘不会硬锯齿。
6.4 主函数:blur 用「5 次采样」而不是「多层 fill」
上一篇文章里 BorderBeam 的 blur 是「N 层 radial fill 硬堆」,而 MetalFx 的 blur 是 5 次贴面采样:
void main() {
vec2 uv = gl_FragCoord.xy / u_resolution;
float aspect = u_resolution.x / u_resolution.y;
vec3 color;
if (u_blur < 0.01) {
color = computeEffect(uv, aspect, u_time);
} else {
float radius = u_blur * 0.02;
color = computeEffect(uv, aspect, u_time) * 0.4;
color += computeEffect(uv + vec2(radius, 0.0), aspect, u_time) * 0.15; // 右
color += computeEffect(uv - vec2(radius, 0.0), aspect, u_time) * 0.15; // 左
color += computeEffect(uv + vec2(0.0, radius), aspect, u_time) * 0.15; // 上
color += computeEffect(uv - vec2(0.0, radius), aspect, u_time) * 0.15; // 下
}
// ... vignette 暗角
float alpha = ringMask(gl_FragCoord.xy) * paletteAlpha * u_shaderOpacity * clamp(u_strength, 0.0, 1.0);
fragColor = vec4(color * alpha, alpha); // 预乘 alpha,透明叠加
}
同样要「模糊」,BorderBeam 是上百次 Canvas fill,MetalFx 是5 次贴面采样——这就是 GPU vs CPU 的本质差距。
七、诚实交代:三个「真机闸门」还没过
E024 的实验文档非常克制地标注了状态。这一点必须如实写清楚:
已验证(工程链):
|
项目 |
结果 |
|---|---|
|
Native arm64-v8a / x86_64 |
编译通过 |
|
CompileArkTS / PackageHap / SignHap |
BUILD SUCCESSFUL |
|
HAP 内 |
两个 ABI 均确认存在( |
|
圆角环 mask |
已从 Canvas |
待真机验证(当时无 hdc 目标):
-
TEXTURE RGBA 的预乘 Alpha 是否在目标手机上稳定保留透明中心(最关键的闸门:如果中心不透明,整个「透明叠层」设计就失败了)。
-
XComponent 叠在 Button 上方 +
HitTestMode.None时,点击、按压态、无障碍是否完全透传。 -
Plasma 视觉尺度与上游 shared-canvas sampling 的接近程度。
-
单实例 60fps、三实例 ≥30fps 的性能目标。
-
反复进出页面 20 次,EGL Surface/Context 是否无泄漏。
实验文档甚至写明了「失败策略」:如果透明闸门失败,停止 E024,不改走 Canvas(避免重蹈 BorderBeam 覆辙);如果 shader 编译失败,保存驱动日志,不用静默的简化 shader 替代。这套「闸门 + 失败策略」的严谨性,正是 ArkUILab「Research first」的体现。
八、工程要点清单(可直接当 Review Checklist)
-
内容用
@LocalBuilder→@BuilderParam;语义与触摸归内容节点所有。 -
XComponent
type: XComponentType.TEXTURE、libraryname: 'metalfx'、backgroundColor('#00000000')、hitTestBehavior: HitTestMode.None。 -
ArkTS 尺寸是 vp,传给 shader 的
radiusPx/ringPx必须vp2px换算。 -
配置过 NAPI 边界要序列化成扁平对象(
colors是Array<number>,不是 class)。 -
每个 XComponent 实例独立 EGL Surface/Context,用
ComponentId隔离,别混淆。 -
共享 EGLDisplay 用引用计数,避免 library 卸载时析构顺序问题。
-
Surface 未就绪时允许先缓存 config;主题/强度变化后即使暂停也画一帧。
-
离屏、Pause、页面消失都停止 Native frame callback;
SurfaceDestroyed释放实例。 -
EGL/Shader 失败时保持透明并记结构化日志,不能影响子内容可用性。
-
帧时间
delta要clamp(0.1),防后台恢复突跳。
九、总结:Native GPU 是「能力天花板」的正解
把 E023(BorderBeam)和 E024(MetalFx)放在一起看,正好是鸿蒙上做「高级视觉效果」的一正一反两面教材:
|
维度 |
E023 BorderBeam(反面) |
E024 MetalFx(正面方向) |
|---|---|---|
|
blur 实现 |
多层 radial soft fill(CPU) |
5 次贴面采样(GPU) |
|
环 mask |
|
rounded-rect SDF(fragment shader) |
|
帧成本 |
150~250 fills/帧 → 卡 |
逐像素 shader,GPU 并行 |
|
结论 |
封板,暂不推荐生产 |
编译链打通,待真机闸门 |
核心结论:
「糊」和「噪波」和「金属流动」这类逐像素效果,本质是 GPU 的活。
Canvas 适合几何绘制,不适合模拟 blur——这是 BorderBeam 用卡顿换来的教训。
鸿蒙并非没有 GPU 入口:XComponentType.TEXTURE+ NAPI + C++ + EGL/GLES3 就是那条路。
但 Native 意味着更高的门槛和更多待验证的闸门,文档要诚实区分「已验证」与「待验证」。
MetalFx 目前是「工程链已打通、真机待验证」的Draft状态,尚未提升为 Validated、也还没上 ADR。但它的价值在于:它证明了一条纯 ArkTS 团队也能增量接入 C++/OpenGL ES 3的可行路径——不需要拆新模块,只需在 entry 里加 CMakeLists.txt + 几个 .cpp,HAP 就能带上 libmetalfx.so,把那些「Canvas 做不动」的效果交给 GPU。
如果你正在为鸿蒙上「发光的按钮、金属质感的边框、流动的等离子光环」发愁,并且不想重蹈「Canvas 硬算到卡死」的覆辙——这篇就是给你的路线图:别硬算,去用 GPU。
更多推荐





所有评论(0)