Mobile Development 21 min read

HarmonyOS 6 3D Spotlight: One Light Pulls Models from Darkness

The article demonstrates creating a dramatic 3D spotlight effect on HarmonyOS 6 by setting up a near-dark environment, adding a configurable spotlight with adjustable intensity, cone angle, and color temperature, and providing complete ArkTS code for real-time interactive lighting control on a MatePad Edge device.

51CTO HarmonyOS Developer Community
51CTO HarmonyOS Developer Community
51CTO HarmonyOS Developer Community
HarmonyOS 6 3D Spotlight: One Light Pulls Models from Darkness

What Is Light in 3D

After finishing a skybox demo, the author asked why some 3D scenes look like game screenshots while others resemble dull display-case samples. The answer: the difference isn't the model — it's the light. Models decide "what exists"; lighting decides "whether it feels alive."

This article focuses on a single spotlight (SpotLight). The ambient light is reduced to near-black, then a conical beam is aimed from above at a slant. The moment the light cone catches the model, contrast, silhouette, and material texture all appear — a "one beam rescues the whole scene" effect more intuitive than any screenshot.

The demo goes further: the light becomes a control console — toggle on/off, adjust brightness, widen/narrow the cone, switch among three color temperatures. Environment: DevEco Studio 6.1.1 Release + HarmonyOS 6.1.1 Release SDK (API 24). 3D rendering must run on a Huawei MatePad Edge real device; the emulator only guarantees compilation.

Staging: Make the World Dark First

A spotlight needs darkness to show its drama. If the scene already has bright ambient light, even a strong spotlight is just "icing on the cake" — the "pulling out of darkness" theatricality disappears. Step one: wrap the scene in a dark environment.

Dark environment setup
Dark environment setup
const envDark: Environment = await factory.createEnvironment({ name: 'env_dark' });
envDark.backgroundType = EnvironmentBackgroundType.BACKGROUND_NONE;
// Environment gives almost no light, just a faint glow to avoid pure black
envDark.indirectDiffuseFactor = { x: 0.08, y: 0.08, z: 0.1, w: 1 };
envDark.indirectSpecularFactor = { x: 0.05, y: 0.05, z: 0.08, w: 1 };
result.environment = envDark;
indirectDiffuseFactor

controls ambient diffuse intensity; indirectSpecularFactor controls ambient specular intensity. The Vec4 components are RGBA (named x/y/z/w per graphics3d convention). The author deliberately left 8% residual glow — pure black would hide the model's base appearance; a trace of ambient light makes the spotlight contrast more striking.

This step is the key to understanding 3D lighting systems: final scene brightness = ambient light + all lights. Ambient sets the global base; lights do local shaping. For "stage feel", suppress ambient; for "natural feel", let ambient dominate.

Lighting: How to Position a Spotlight

With the environment ready, the star enters. ArkGraphics3D uses SceneResourceFactory.createLight; the second argument specifies type — LightType.SPOT for spotlight.

Spotlight creation
Spotlight creation
const spot: SpotLight = await factory.createLight({ name: 'spot1' }, LightType.SPOT) as SpotLight;
spot.enabled = true;
spot.intensity = 3;
spot.color = { r: 1, g: 0.94, b: 0.82, a: 1 };  // warm white
spot.position = { x: 0, y: 3.6, z: 1.8 };          // above and to the side of model
spot.rotation = { x: Math.sin(-0.95 / 2), y: 0, z: 0, w: Math.cos(-0.95 / 2) }; // aimed down-forward
spot.outerAngle = 0.8;                               // cone angle (radians)

The light is a node, so position uses position and orientation uses a quaternion rotation. Placed at y=3.6, z=1.8 (above and slightly to the side), rotated around X by about -54°, the cone slants downward — the classic "stage follow-spot" angle that illuminates the top while leaving side transitions between light and shadow.

Why warm white instead of pure white? In 3D, light color behaves like stage lighting: warm light makes lit faces slightly yellow and shadow faces slightly blue, instantly boosting depth. Pure white is "technically correct"; warm white is "visually correct."

Dimming: Intensity and Cone Angle Are Two Knobs

Placing the light isn't enough; it must be adjustable. Two sliders bind directly to the light's properties:

Light intensity intensity : 0 to 6. At 0 the light is effectively off (same as the off switch); at max the image nearly overexposes, showing the side-effect of "too much light".

Cone angle outerAngle : 0.2 to 1.2 radians (~11°–69°). Small values = "flashlight beam"; large values = "stage flood".

Intensity and cone angle sliders
Intensity and cone angle sliders
private setIntensity(v: number) {
  this.intensity = v;
  if (this.spot) this.spot.intensity = v;
}

Both sliders take effect in real time: every frame dragged updates the light parameters, and the model's shading changes instantly. This "what-you-see-is-what-you-get" tuning is far more efficient than editing numbers in code and recompiling — which is why every 3D tool builds parameter panels.

Debugging tip: first shrink outerAngle to minimum (narrowest cone). You can then clearly see the exact contact patch between cone and model, verifying position and aim. Once confirmed, slowly widen the angle until coverage is satisfactory. This workflow beats blind trial-and-error.

Color Swapping: The Mood of Light

Light doesn't just illuminate; it conveys mood. Three preset colors, one-tap switching:

Warm white : default, reproduces the object's true colors, most "neutral".

Amber : orange-yellow, simulates sunset or stage lighting — the whole scene instantly "warms up".

Ice blue : cool tone, simulates moonlight or a cold atmosphere — the model looks like it stands under a night streetlamp.

private switchColor() {
  if (!this.spot) return;
  this.colorIndex = (this.colorIndex + 1) % this.colors.length;
  this.spot.color = this.colors[this.colorIndex];
}

When switching, watch the model's shadow side: under warm light the shadow leans cool blue; under cool light the shadow leans warm orange — a natural complementary-color reaction, and one of the most charming details in 3D lighting. Cycling the three colors gradually builds an intuition that "color = mood."

Complete Code — Run Directly

Below is the full Index.ets. Resource files ( sample_model.gltf, sample_model.bin) go in rawfile; the project uses the Empty Ability template — replace the page file and drop the model into rawfile.

Full code screenshot
Full code screenshot
interface LightColor {
  r: number;
  g: number;
  b: number;
  a: number;
}

import { Scene, Node, Camera, Environment, EnvironmentBackgroundType, Light, SpotLight, LightType, NodeType } from '@kit.ArkGraphics3D';

@Entry
@Component
struct Index {
  private scene: Scene | null = null;
  private cubeNode: Node | null = null;
  private spot: SpotLight | null = null;
  private envDark: Environment | null = null;
  private timerId: number = -1;
  private modelAngle: number = 0;
  private lightOn: boolean = true;
  private colorIndex: number = 0;
  private colors: LightColor[] = [
    { r: 1, g: 0.94, b: 0.82, a: 1 },
    { r: 1, g: 0.55, b: 0.25, a: 1 },
    { r: 0.6, g: 0.85, b: 1, a: 1 }
  ];
  @State isOn: boolean = true;
  @State intensity: number = 3;
  @State outerAngle: number = 0.8;
  @State colorName: string = '暖白';
  @State modelReady: boolean = false;
  @State errorMsg: string = '';

  aboutToAppear() {
    this.initScene();
  }

  aboutToDisappear() {
    this.stopSpin();
  }

  private async initScene() {
    try {
      const result: Scene = await Scene.load($rawfile('sample_model.gltf'));
      this.scene = result;
      const factory = result.getResourceFactory();

      // Camera
      const camera: Camera = await factory.createCamera({ name: 'camera1' });
      camera.enabled = true;
      camera.position = { x: 0, y: 0.4, z: 4.6 };

      // Dark environment: almost no ambient light, stage left to spotlight
      const envDark: Environment = await factory.createEnvironment({ name: 'env_dark' });
      envDark.backgroundType = EnvironmentBackgroundType.BACKGROUND_NONE;
      envDark.indirectDiffuseFactor = { x: 0.08, y: 0.08, z: 0.1, w: 1 };
      envDark.indirectSpecularFactor = { x: 0.05, y: 0.05, z: 0.08, w: 1 };
      this.envDark = envDark;
      result.environment = envDark;

      // Spotlight: from above and side
      const spot: SpotLight = await factory.createLight({ name: 'spot1' }, LightType.SPOT) as SpotLight;
      spot.enabled = true;
      spot.intensity = this.intensity;
      spot.color = this.colors[0];
      spot.position = { x: 0, y: 3.6, z: 1.8 };
      // Rotate around X so cone aims down-forward
      spot.rotation = { x: Math.sin(-0.95 / 2), y: 0, z: 0, w: Math.cos(-0.95 / 2) };
      spot.outerAngle = this.outerAngle;
      this.spot = spot;

      // Find model node
      const root = result.root;
      if (root) {
        for (let i = 0; i < root.children.count(); i++) {
          const child = root.children.get(i);
          if (child && child.nodeType !== NodeType.CAMERA) {
            this.cubeNode = child;
            break;
          }
        }
      }

      this.modelReady = true;
      this.startSpin();
    } catch (err) {
      const e = err as Error;
      this.errorMsg = e && e.message ? e.message : JSON.stringify(err);
      console.error('场景初始化失败: ' + this.errorMsg);
    }
  }

  private startSpin() {
    if (this.timerId !== -1) return;
    this.timerId = setInterval(() => {
      this.modelAngle += 0.015;
      if (this.modelAngle >= 2 * Math.PI) this.modelAngle -= 2 * Math.PI;
      if (this.cubeNode) {
        this.cubeNode.rotation = {
          x: 0, y: Math.sin(this.modelAngle / 2), z: 0, w: Math.cos(this.modelAngle / 2)
        };
      }
    }, 16);
  }

  private stopSpin() {
    if (this.timerId !== -1) {
      clearInterval(this.timerId);
      this.timerId = -1;
    }
  }

  private toggleLight() {
    if (!this.spot) return;
    this.lightOn = !this.lightOn;
    this.spot.enabled = this.lightOn;
    this.isOn = this.lightOn;
  }

  private switchColor() {
    if (!this.spot) return;
    this.colorIndex = (this.colorIndex + 1) % this.colors.length;
    this.spot.color = this.colors[this.colorIndex];
    const names: string[] = ['暖白', '琥珀', '冰蓝'];
    this.colorName = names[this.colorIndex];
  }

  private setIntensity(v: number) {
    this.intensity = v;
    if (this.spot) this.spot.intensity = v;
  }

  private setOuter(v: number) {
    this.outerAngle = v;
    if (this.spot) this.spot.outerAngle = v;
  }

  build() {
    Stack({ alignContent: Alignment.Bottom }) {
      Column() {
        if (this.modelReady && this.scene) {
          Component3D({ scene: this.scene, modelType: ModelType.SURFACE })
            .width('100%')
            .height('100%')
        } else {
          this.loadingView()
        }
      }
      .width('100%')
      .height('100%')

      // Lighting console
      Column({ space: 12 }) {
        Row() {
          Text('3D 聚光灯控制台')
            .fontSize(18)
            .fontWeight(FontWeight.Bold)
            .fontColor(Color.White)
          Blank()
          Text(this.isOn ? '灯光已开启' : '灯光已关闭')
            .fontSize(13)
            .fontColor(this.isOn ? '#80D8FF' : '#9E9E9E')
        }
        .width('100%')

        Row({ space: 12 }) {
          Button(this.isOn ? '关灯' : '开灯')
            .layoutWeight(1)
            .height(36)
            .fontSize(14)
            .backgroundColor(this.isOn ? '#455A64' : '#2B5CE6')
            .onClick(() => { this.toggleLight(); })
          Button('换色:' + this.colorName)
            .layoutWeight(1)
            .height(36)
            .fontSize(14)
            .backgroundColor('#7B1FA2')
            .onClick(() => { this.switchColor(); })
        }
        .width('100%')

        Column({ space: 6 }) {
          Row() {
            Text('光照强度')
              .fontSize(13)
              .fontColor('rgba(255,255,255,0.85)')
            Blank()
            Text(this.intensity.toFixed(1))
              .fontSize(13)
              .fontColor('#FFFFFF')
          }
          .width('100%')
          Slider({ value: this.intensity, min: 0, max: 6, step: 0.1, style: SliderStyle.OutSet })
            .width('100%')
            .onChange((value: number) => { this.setIntensity(value); })
        }
        .width('100%')

        Column({ space: 6 }) {
          Row() {
            Text('光锥角度')
              .fontSize(13)
              .fontColor('rgba(255,255,255,0.85)')
            Blank()
            Text((this.outerAngle * 180 / Math.PI).toFixed(0) + '°')
              .fontSize(13)
              .fontColor('#FFFFFF')
          }
          .width('100%')
          Slider({ value: this.outerAngle, min: 0.2, max: 1.2, step: 0.05, style: SliderStyle.OutSet })
            .width('100%')
            .onChange((value: number) => { this.setOuter(value); })
        }
        .width('100%')

        Text('提示:3D 渲染请用真机体验。关灯后只剩环境微光,模型几乎沉入黑暗;开灯后光锥照亮的部分立刻立体起来.')
          .fontSize(12)
          .fontColor('rgba(255,255,255,0.7)')
          .lineHeight(18)
        }
        .width('100%')
        .padding(16)
        .backgroundColor('rgba(0,0,0,0.6)')
        .borderRadius(16)
        .margin(12)
      }
      .width('100%')
      .height('100%')
      .backgroundColor('#0A0F18')
    }
  }

  @Builder
  loadingView() {
    Column() {
      if (this.errorMsg !== '') {
        Text('场景初始化失败:' + this.errorMsg)
          .fontSize(13)
          .fontColor('#FF6B6B')
          .textAlign(TextAlign.Center)
          .padding(16)
      } else {
        Text('场景加载中...')
          .fontSize(16)
          .fontColor('#8899AA')
      }
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
  }
}

Real-Device Effect

Running on a MatePad Edge: the screen shows a near-pitch-black scene, only 8% ambient glow outlining the model. Tap "On" — a warm-white cone slashes down from the upper right; the lit side instantly reveals material texture, the shadow side sinks into darkness, the terminator sharp as a knife — stage presence appears instantly.

Drag "Cone Angle" to minimum: the beam narrows to a thin flashlight strip, only a tiny patch on the model lights up, like searching with a torch at midnight. Widen it: the cone opens into a flood, the whole model bathes in light. Back and forth — you develop a feel for "cone angle."

"Intensity" at 0 returns to the dark scene; at 6 it overexposes to white — proving light isn't "brighter is better"; the right intensity is key. Finally tap "Change Color": amber turns the scene into dusk or stage; ice blue brings a cold midnight vibe. Three colors, three moods, one tap each.

Real device effect
Real device effect

Wrap-Up

The core of this article is one sentence: 3D scene quality is "tuned", not "painted". Dark environment sets the tone; spotlight does the shaping; intensity, cone angle, and color temperature are your three knobs. Mastering light elevates scene quality more than mastering modeling.

Two homework exercises: (1) Swap the spotlight for a DirectionalLight (parallel light) and compare the shaping difference — parallel light acts like the sun, spotlight acts like a stage. (2) Add a second light (e.g., a cool fill from behind) to create classic three-point lighting (key + fill + rim) and see how much more volumetric a single model becomes. The world of light gets deeper the more you play.

References

@kit.ArkGraphics3D Scene Resources (Official API Reference)

ArkTS 3D Scene Component Component3D (Official Documentation)

Slider Component (Official API Reference)

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

mobile developmentreal-time renderinglightingArkTS3D graphicsHarmonyOS 6ArkGraphics3Dspotlight
51CTO HarmonyOS Developer Community
Written by

51CTO HarmonyOS Developer Community

The HarmonyOS Developer Community is a learning-oriented community for developers to learn, communicate, ask questions, and share.

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.