@needle-tools/engine
Version:
Needle Engine is a web-based runtime for 3D apps. It runs on your machine for development with great integrations into editors like Unity or Blender - and can be deployed onto any device! It is flexible, extensible and networking and XR are built-in.
230 lines (211 loc) • 10.1 kB
text/typescript
import { AdditiveBlending, Material, Mesh, MeshBasicMaterial, MeshStandardMaterial,ShadowMaterial } from "three";
import { ObjectUtils, PrimitiveType } from "../engine/engine_create_objects.js";
import { serializable } from "../engine/engine_serialization_decorator.js";
import { RGBAColor } from "../engine/js-extensions/index.js";
import { Behaviour } from "./Component.js";
import type { ContactShadows } from "./ContactShadows.js";
import type { Light } from "./Light.js";
import type { Renderer } from "./Renderer.js";
/**
* The mode of the ShadowCatcher.
* - ShadowMask: only renders shadows.
* - Additive: renders shadows additively.
* - Occluder: occludes light.
*/
enum ShadowMode {
ShadowMask = 0,
Additive = 1,
Occluder = 2,
}
/**
* ShadowCatcher renders real-time shadows cast by lights onto a mesh surface.
* Captures actual shadow data from the scene's lighting system (directional lights, point lights, spot lights).
*
* If the GameObject is a Mesh, it applies a shadow-catching material to it.
* Otherwise, it creates a quad mesh with the shadow-catching material automatically.
*
* [](https://engine.needle.tools/samples/shadow-catcher/)
* *Additive ShadowCatcher mode with point light shadows*
*
* [](https://engine.needle.tools/samples/transmission/)
* *ShadowCatcher with directional light shadows*
*
* **Shadow Modes:**
* - `ShadowMask` - Only renders shadows (works best with directional lights)
* - `Additive` - Renders light additively (works best with point/spot lights)
* - `Occluder` - Occludes light without rendering shadows
*
* **ShadowCatcher vs ContactShadows:**
* - **ShadowCatcher**: Real-time shadows from actual lights. Accurate directional shadows that match light sources. Requires lights with shadows enabled. Updates every frame.
* - **{@link ContactShadows}**: Proximity-based ambient occlusion-style shadows. Extremely soft and diffuse, ideal for subtle grounding. Better performance, works without lights.
*
* **When to use ShadowCatcher:**
* - You need accurate shadows that match specific light directions
* - Scene has real-time lighting with shadow-casting lights
* - Shadows need to follow light attenuation and angles
* - AR/VR scenarios where light estimation is available
* - Hard or semi-hard shadow edges are desired
*
* **When to use ContactShadows instead:**
* - You want very soft, ambient occlusion-style ground shadows
* - Performance is critical (no per-frame shadow rendering)
* - Scene doesn't have shadow-casting lights
* - Product visualization or configurators (subtle grounding effect)
* - Soft, diffuse shadows are more visually appealing than accurate ones
*
* **Note:** ShadowCatcher meshes are not raycastable by default (layer 2). Change layers in `onEnable()` if raycasting is needed.
*
* @example Basic shadow catcher plane
* ```ts
* const plane = new Object3D();
* const catcher = addComponent(plane, ShadowCatcher);
* catcher.mode = ShadowMode.ShadowMask;
* catcher.shadowColor = new RGBAColor(0, 0, 0, 0.8);
* ```
*
* @example Apply to existing mesh
* ```ts
* const mesh = this.gameObject.getComponent(Mesh);
* const catcher = addComponent(mesh, ShadowCatcher);
* // The mesh will now catch shadows from scene lights
* ```
*
* @summary Renders real-time shadows from lights onto surfaces
* @category Rendering
* @group Components
* @see {@link ContactShadows} for proximity-based fake shadows (better performance)
* @see {@link Light} for shadow-casting light configuration
* @see {@link Renderer} for shadow receiving settings
* @link https://engine.needle.tools/samples/shadow-catcher/
* @link https://engine.needle.tools/samples/transmission/
*/
export class ShadowCatcher extends Behaviour {
//@type Needle.Engine.ShadowCatcher.Mode
mode: ShadowMode = ShadowMode.ShadowMask;
//@type UnityEngine.Color
shadowColor: RGBAColor = new RGBAColor(0, 0, 0, 1);
private targetMesh?: Mesh;
/** @internal */
start() {
// if there's no geometry, make a basic quad
if (!(this.gameObject instanceof Mesh)) {
const quad = ObjectUtils.createPrimitive(PrimitiveType.Quad, {
name: "ShadowCatcher",
material: new MeshStandardMaterial({
// HACK heuristic to get approx. the same colors out as with the current default ShadowCatcher material
// not clear why this is needed; assumption is that the Renderer component does something we're not respecting here
color: 0x999999,
roughness: 1,
metalness: 0,
transparent: true,
})
});
quad.receiveShadow = true;
quad.geometry.rotateX(-Math.PI / 2);
// TODO breaks shadow catching right now
// const renderer = new Renderer();
// renderer.receiveShadows = true;
// GameObject.addComponent(quad, Renderer);
this.gameObject.add(quad);
this.targetMesh = quad;
}
else if (this.gameObject instanceof Mesh && this.gameObject.material) {
// make sure we have a unique material to work with
this.gameObject.material = this.gameObject.material.clone();
this.targetMesh = this.gameObject;
// make sure the mesh can receive shadows
this.targetMesh.receiveShadow = true;
}
if(!this.targetMesh) {
console.warn("ShadowCatcher: no mesh to apply shadow catching to. Groups are currently not supported.");
return;
}
// Shadowcatcher mesh isnt raycastable
this.targetMesh.layers.set(2);
switch (this.mode) {
case ShadowMode.ShadowMask:
this.applyShadowMaterial();
break;
case ShadowMode.Additive:
this.applyLightBlendMaterial();
break;
case ShadowMode.Occluder:
this.applyOccluderMaterial();
break;
}
}
// Custom blending, diffuse-only lighting blended onto the scene additively.
// Works great for Point Lights and spot lights,
// doesn't work for directional lights (since they're lighting up everything else).
// Works even better with an additional black-ish gradient to darken parts of the AR scene
// so that lights become more visible on bright surfaces.
applyLightBlendMaterial() {
if (!this.targetMesh) return;
const material = this.targetMesh.material as Material;
material.blending = AdditiveBlending;
this.applyMaterialOptions(material);
material.onBeforeCompile = (shader) => {
// see https://github.com/mrdoob/three.js/blob/dev/src/renderers/shaders/ShaderLib/meshphysical.glsl.js#L181
// see https://github.com/mrdoob/three.js/blob/dev/src/renderers/shaders/ShaderLib.js#LL284C11-L284C11
// see https://github.com/mrdoob/three.js/blob/dev/src/renderers/shaders/ShaderLib/shadow.glsl.js#L40
// see https://github.com/mrdoob/three.js/blob/dev/src/renderers/shaders/ShaderChunk/shadowmask_pars_fragment.glsl.js#L2
// see https://github.com/mrdoob/three.js/blob/dev/src/renderers/shaders/ShaderChunk/shadowmap_pars_fragment.glsl.js#L281
shader.fragmentShader = shader.fragmentShader.replace("vec3 outgoingLight = totalDiffuse + totalSpecular + totalEmissiveRadiance;",
`vec3 outgoingLight = totalDiffuse + totalSpecular + totalEmissiveRadiance;
// diffuse-only lighting with overdrive to somewhat compensate
// for the loss of indirect lighting and to make it more visible.
vec3 direct = (reflectedLight.directDiffuse + reflectedLight.directSpecular) * 6.6;
float max = max(direct.r, max(direct.g, direct.b));
// early out - we're simply returning direct lighting and some alpha based on it so it can
// be blended onto the scene.
gl_FragColor = vec4(direct, max);
return;
`);
}
material.userData.isLightBlendMaterial = true;
}
// ShadowMaterial: only does a mask; shadowed areas are fully black.
// doesn't take light attenuation into account.
// works great for Directional Lights.
applyShadowMaterial() {
if (this.targetMesh) {
if ((this.targetMesh.material as Material).type !== "ShadowMaterial") {
const material = new ShadowMaterial();
material.color = this.shadowColor;
material.opacity = this.shadowColor.alpha;
this.applyMaterialOptions(material);
this.targetMesh.material = material;
material.userData.isShadowCatcherMaterial = true;
}
else {
const material = this.targetMesh.material as ShadowMaterial;
material.color = this.shadowColor;
material.opacity = this.shadowColor.alpha;
this.applyMaterialOptions(material);
material.userData.isShadowCatcherMaterial = true;
}
}
}
applyOccluderMaterial() {
if (this.targetMesh) {
let material = this.targetMesh.material as Material;
if (!material) {
const mat = new MeshBasicMaterial();
this.targetMesh.material = mat;
material = mat;
}
material.depthWrite = true;
material.stencilWrite = true;
material.colorWrite = false;
this.gameObject.renderOrder = -100;
}
}
private applyMaterialOptions(material: Material) {
if (material) {
material.depthWrite = false;
material.stencilWrite = false;
}
}
}