一、前言:为什么偏偏是 MetalFx 要上 Native?

先讲清楚动机。上一系列(GrokBot、ThinkingOrbs、FlowAvatar、GradientSpin)全是「纯 ArkTS + Canvas + DisplaySync」,跑得很好。但 BorderBeam(E023)暴露了一个能力天花板:

filter: blur(8~22px) 这种「一次糊整层」的 GPU 操作,无法用「多层 soft fill」在 CPU/Canvas 侧廉价近似。

metal-fxJakubantalik/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();
  })
}

四个要点:

  1. XComponentType.TEXTURE —— 提供一个原生渲染的纹理层,可做到中心透明(RGBA8),正好用来叠在内容上的「金属环」,环外透明、环内透明,只有环本身有颜色。

  2. libraryname: 'metalfx' —— 关联编译出的 libmetalfx.so。ArkTS 不用 import 那个 .so,而是通过 onLoadcontext 拿到 Native 暴露的方法。

  3. hitTestBehavior: HitTestMode.None —— 让点击、按压、无障碍完全透传给下面的内容节点。

  4. 定位叠层 —— 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.etsbuildMetalFxNativeConfig 做这件事:

// 拿到 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 通过 onLoadexports 传给 ArkTS,CExport 里往这个 exports 上挂方法,同时从隐藏属性 OH_NATIVE_XCOMPONENT_OBJunwrap 出原生指针,再用 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_OBJunwrap、再查表——因为 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;
}

注意每个实例拥有独立的 EGLSurfurfaceEGLContext、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 是分形布朗运动(叠多倍频噪声),nfbmcomplexity 参数控制 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 内 .so

两个 ABI 均确认存在(libs/arm64-v8a/libmetalfx.so + libs/x86_64/libmetalfx.so

圆角环 mask

已从 Canvas destination-out 改为 fragment shader SDF

待真机验证(当时无 hdc 目标)

  1. TEXTURE RGBA 的预乘 Alpha 是否在目标手机上稳定保留透明中心(最关键的闸门:如果中心不透明,整个「透明叠层」设计就失败了)。

  2. XComponent 叠在 Button 上方 + HitTestMode.None 时,点击、按压态、无障碍是否完全透传

  3. Plasma 视觉尺度与上游 shared-canvas sampling 的接近程度。

  4. 单实例 60fps、三实例 ≥30fps 的性能目标。

  5. 反复进出页面 20 次,EGL Surface/Context 是否无泄漏。

实验文档甚至写明了「失败策略」:如果透明闸门失败,停止 E024,不改走 Canvas(避免重蹈 BorderBeam 覆辙);如果 shader 编译失败,保存驱动日志,不用静默的简化 shader 替代。这套「闸门 + 失败策略」的严谨性,正是 ArkUILab「Research first」的体现。


八、工程要点清单(可直接当 Review Checklist)

  • 内容用 @LocalBuilder@BuilderParam;语义与触摸归内容节点所有。

  • XComponent type: XComponentType.TEXTURElibraryname: 'metalfx'backgroundColor('#00000000')hitTestBehavior: HitTestMode.None

  • ArkTS 尺寸是 vp,传给 shader 的 radiusPx / ringPx 必须 vp2px 换算。

  • 配置过 NAPI 边界要序列化成扁平对象colorsArray<number>,不是 class)。

  • 每个 XComponent 实例独立 EGL Surface/Context,用 ComponentId 隔离,别混淆。

  • 共享 EGLDisplay 用引用计数,避免 library 卸载时析构顺序问题。

  • Surface 未就绪时允许先缓存 config;主题/强度变化后即使暂停也画一帧

  • 离屏、Pause、页面消失都停止 Native frame callbackSurfaceDestroyed 释放实例。

  • EGL/Shader 失败时保持透明并记结构化日志,不能影响子内容可用性。

  • 帧时间 deltaclamp(0.1),防后台恢复突跳。


九、总结:Native GPU 是「能力天花板」的正解

把 E023(BorderBeam)和 E024(MetalFx)放在一起看,正好是鸿蒙上做「高级视觉效果」的一正一反两面教材

维度

E023 BorderBeam(反面)

E024 MetalFx(正面方向)

blur 实现

多层 radial soft fill(CPU)

5 次贴面采样(GPU)

环 mask

destination-out + destination-in

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。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐