UNPKG

@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
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://cloud.needle.tools/-/media/pFXPchA4vynNKOjgG_KucQ.gif)](https://engine.needle.tools/samples/shadow-catcher/) * *Additive ShadowCatcher mode with point light shadows* * * [![](https://cloud.needle.tools/-/media/oIWgEU49rEA0xJ2TrbzVlg.gif)](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 @serializable() mode: ShadowMode = ShadowMode.ShadowMask; //@type UnityEngine.Color @serializable(RGBAColor) 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; } } }