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.
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.
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; indirectDiffuseFactorcontrols 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.
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".
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.
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.
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)
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
51CTO HarmonyOS Developer Community
The HarmonyOS Developer Community is a learning-oriented community for developers to learn, communicate, ask questions, and share.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
