Home Reference Source

src/effects/OutlineEffect.js

import { Color, RepeatWrapping, Uniform, UnsignedByteType, WebGLRenderTarget } from "three";
import { Resolution } from "../core/Resolution.js";
import { Selection } from "../core/Selection.js";
import { BlendFunction } from "../enums/BlendFunction.js";
import { KernelSize } from "../enums/KernelSize.js";
import { DepthComparisonMaterial } from "../materials/DepthComparisonMaterial.js";
import { OutlineMaterial } from "../materials/OutlineMaterial.js";
import { KawaseBlurPass } from "../passes/KawaseBlurPass.js";
import { ClearPass } from "../passes/ClearPass.js";
import { DepthPass } from "../passes/DepthPass.js";
import { RenderPass } from "../passes/RenderPass.js";
import { ShaderPass } from "../passes/ShaderPass.js";
import { Effect } from "./Effect.js";

import fragmentShader from "./glsl/outline.frag";
import vertexShader from "./glsl/outline.vert";

/**
 * An outline effect.
 */

export class OutlineEffect extends Effect {

	/**
	 * Constructs a new outline effect.
	 *
	 * @param {Scene} scene - The main scene.
	 * @param {Camera} camera - The main camera.
	 * @param {Object} [options] - The options.
	 * @param {BlendFunction} [options.blendFunction=BlendFunction.SCREEN] - The blend function. Use `BlendFunction.ALPHA` for dark outlines.
	 * @param {Texture} [options.patternTexture=null] - A pattern texture.
	 * @param {Number} [options.patternScale=1.0] - The pattern scale.
	 * @param {Number} [options.edgeStrength=1.0] - The edge strength.
	 * @param {Number} [options.pulseSpeed=0.0] - The pulse speed. A value of zero disables the pulse effect.
	 * @param {Number} [options.visibleEdgeColor=0xffffff] - The color of visible edges.
	 * @param {Number} [options.hiddenEdgeColor=0x22090a] - The color of hidden edges.
	 * @param {KernelSize} [options.kernelSize=KernelSize.VERY_SMALL] - The blur kernel size.
	 * @param {Boolean} [options.blur=false] - Whether the outline should be blurred.
	 * @param {Boolean} [options.xRay=true] - Whether occluded parts of selected objects should be visible.
	 * @param {Number} [options.multisampling=0] - The number of samples used for multisample antialiasing. Requires WebGL 2.
	 * @param {Number} [options.resolutionScale=0.5] - The resolution scale.
	 * @param {Number} [options.resolutionX=Resolution.AUTO_SIZE] - The horizontal resolution.
	 * @param {Number} [options.resolutionY=Resolution.AUTO_SIZE] - The vertical resolution.
	 * @param {Number} [options.width=Resolution.AUTO_SIZE] - Deprecated. Use resolutionX instead.
	 * @param {Number} [options.height=Resolution.AUTO_SIZE] - Deprecated. Use resolutionY instead.
	 */

	constructor(scene, camera, {
		blendFunction = BlendFunction.SCREEN,
		patternTexture = null,
		patternScale = 1.0,
		edgeStrength = 1.0,
		pulseSpeed = 0.0,
		visibleEdgeColor = 0xffffff,
		hiddenEdgeColor = 0x22090a,
		kernelSize = KernelSize.VERY_SMALL,
		blur = false,
		xRay = true,
		multisampling = 0,
		resolutionScale = 0.5,
		width = Resolution.AUTO_SIZE,
		height = Resolution.AUTO_SIZE,
		resolutionX = width,
		resolutionY = height
	} = {}) {

		super("OutlineEffect", fragmentShader, {
			uniforms: new Map([
				["maskTexture", new Uniform(null)],
				["edgeTexture", new Uniform(null)],
				["edgeStrength", new Uniform(edgeStrength)],
				["visibleEdgeColor", new Uniform(new Color(visibleEdgeColor))],
				["hiddenEdgeColor", new Uniform(new Color(hiddenEdgeColor))],
				["pulse", new Uniform(1.0)],
				["patternScale", new Uniform(patternScale)],
				["patternTexture", new Uniform(null)]
			])
		});

		// Handle alpha blending.
		this.blendMode.addEventListener("change", (event) => {

			if(this.blendMode.blendFunction === BlendFunction.ALPHA) {

				this.defines.set("ALPHA", "1");

			} else {

				this.defines.delete("ALPHA");

			}

			this.setChanged();

		});

		this.blendMode.blendFunction = blendFunction;
		this.patternTexture = patternTexture;
		this.xRay = xRay;

		/**
		 * The main scene.
		 *
		 * @type {Scene}
		 * @private
		 */

		this.scene = scene;

		/**
		 * The main camera.
		 *
		 * @type {Camera}
		 * @private
		 */

		this.camera = camera;

		/**
		 * A render target for the outline mask.
		 *
		 * @type {WebGLRenderTarget}
		 * @private
		 */

		this.renderTargetMask = new WebGLRenderTarget(1, 1);
		this.renderTargetMask.samples = multisampling;
		this.renderTargetMask.texture.name = "Outline.Mask";
		this.uniforms.get("maskTexture").value = this.renderTargetMask.texture;

		/**
		 * A render target for the edge detection.
		 *
		 * @type {WebGLRenderTarget}
		 * @private
		 */

		this.renderTargetOutline = new WebGLRenderTarget(1, 1, { depthBuffer: false });
		this.renderTargetOutline.texture.name = "Outline.Edges";
		this.uniforms.get("edgeTexture").value = this.renderTargetOutline.texture;

		/**
		 * A clear pass.
		 *
		 * @type {ClearPass}
		 * @private
		 */

		this.clearPass = new ClearPass();
		this.clearPass.overrideClearColor = new Color(0x000000);
		this.clearPass.overrideClearAlpha = 1;

		/**
		 * A depth pass.
		 *
		 * @type {DepthPass}
		 * @private
		 */

		this.depthPass = new DepthPass(scene, camera);

		/**
		 * A depth comparison mask pass.
		 *
		 * @type {RenderPass}
		 * @private
		 */

		this.maskPass = new RenderPass(scene, camera, new DepthComparisonMaterial(this.depthPass.texture, camera));
		const clearPass = this.maskPass.clearPass;
		clearPass.overrideClearColor = new Color(0xffffff);
		clearPass.overrideClearAlpha = 1;

		/**
		 * A blur pass.
		 *
		 * @type {KawaseBlurPass}
		 */

		this.blurPass = new KawaseBlurPass({ resolutionScale, resolutionX, resolutionY, kernelSize });
		this.blurPass.enabled = blur;
		const resolution = this.blurPass.resolution;
		resolution.addEventListener("change", (e) => this.setSize(resolution.baseWidth, resolution.baseHeight));

		/**
		 * An outline detection pass.
		 *
		 * @type {ShaderPass}
		 * @private
		 */

		this.outlinePass = new ShaderPass(new OutlineMaterial());
		const outlineMaterial = this.outlinePass.fullscreenMaterial;
		outlineMaterial.inputBuffer = this.renderTargetMask.texture;

		/**
		 * The current animation time.
		 *
		 * @type {Number}
		 * @private
		 */

		this.time = 0;

		/**
		 * Indicates whether the outlines should be updated.
		 *
		 * @type {Boolean}
		 * @private
		 */

		this.forceUpdate = true;

		/**
		 * A selection of objects that will be outlined.
		 *
		 * @type {Selection}
		 */

		this.selection = new Selection();

		/**
		 * The pulse speed. Set to 0 to disable.
		 *
		 * @type {Number}
		 */

		this.pulseSpeed = pulseSpeed;

	}

	set mainScene(value) {

		this.scene = value;
		this.depthPass.mainScene = value;
		this.maskPass.mainScene = value;

	}

	set mainCamera(value) {

		this.camera = value;
		this.depthPass.mainCamera = value;
		this.maskPass.mainCamera = value;
		this.maskPass.overrideMaterial.copyCameraSettings(value);

	}

	/**
	 * The resolution of this effect.
	 *
	 * @type {Resolution}
	 */

	get resolution() {

		return this.blurPass.resolution;

	}

	/**
	 * Returns the resolution.
	 *
	 * @return {Resizer} The resolution.
	 */

	getResolution() {

		return this.blurPass.getResolution();

	}

	/**
	 * The amount of MSAA samples.
	 *
	 * Requires WebGL 2. Set to zero to disable multisampling.
	 *
	 * @experimental Requires three >= r138.
	 * @type {Number}
	 */

	get multisampling() {

		return this.renderTargetMask.samples;

	}

	set multisampling(value) {

		this.renderTargetMask.samples = value;
		this.renderTargetMask.dispose();

	}

	/**
	 * The pattern scale.
	 *
	 * @type {Number}
	 */

	get patternScale() {

		return this.uniforms.get("patternScale").value;

	}

	set patternScale(value) {

		this.uniforms.get("patternScale").value = value;

	}

	/**
	 * The edge strength.
	 *
	 * @type {Number}
	 */

	get edgeStrength() {

		return this.uniforms.get("edgeStrength").value;

	}

	set edgeStrength(value) {

		this.uniforms.get("edgeStrength").value = value;

	}

	/**
	 * The visible edge color.
	 *
	 * @type {Color}
	 */

	get visibleEdgeColor() {

		return this.uniforms.get("visibleEdgeColor").value;

	}

	set visibleEdgeColor(value) {

		this.uniforms.get("visibleEdgeColor").value = value;

	}

	/**
	 * The hidden edge color.
	 *
	 * @type {Color}
	 */

	get hiddenEdgeColor() {

		return this.uniforms.get("hiddenEdgeColor").value;

	}

	set hiddenEdgeColor(value) {

		this.uniforms.get("hiddenEdgeColor").value = value;

	}

	/**
	 * Returns the blur pass.
	 *
	 * @deprecated Use blurPass instead.
	 * @return {KawaseBlurPass} The blur pass.
	 */

	getBlurPass() {

		return this.blurPass;

	}

	/**
	 * Returns the selection.
	 *
	 * @deprecated Use selection instead.
	 * @return {Selection} The selection.
	 */

	getSelection() {

		return this.selection;

	}

	/**
	 * Returns the pulse speed.
	 *
	 * @deprecated Use pulseSpeed instead.
	 * @return {Number} The speed.
	 */

	getPulseSpeed() {

		return this.pulseSpeed;

	}

	/**
	 * Sets the pulse speed. Set to zero to disable.
	 *
	 * @deprecated Use pulseSpeed instead.
	 * @param {Number} value - The speed.
	 */

	setPulseSpeed(value) {

		this.pulseSpeed = value;

	}

	/**
	 * The current width of the internal render targets.
	 *
	 * @type {Number}
	 * @deprecated Use resolution.width instead.
	 */

	get width() {

		return this.resolution.width;

	}

	set width(value) {

		this.resolution.preferredWidth = value;

	}

	/**
	 * The current height of the internal render targets.
	 *
	 * @type {Number}
	 * @deprecated Use resolution.height instead.
	 */

	get height() {

		return this.resolution.height;

	}

	set height(value) {

		this.resolution.preferredHeight = value;

	}

	/**
	 * The selection layer.
	 *
	 * @type {Number}
	 * @deprecated Use selection.layer instead.
	 */

	get selectionLayer() {

		return this.selection.layer;

	}

	set selectionLayer(value) {

		this.selection.layer = value;

	}

	/**
	 * Indicates whether dithering is enabled.
	 *
	 * @type {Boolean}
	 * @deprecated
	 */

	get dithering() {

		return this.blurPass.dithering;

	}

	set dithering(value) {

		this.blurPass.dithering = value;

	}

	/**
	 * The blur kernel size.
	 *
	 * @type {KernelSize}
	 * @deprecated Use blurPass.kernelSize instead.
	 */

	get kernelSize() {

		return this.blurPass.kernelSize;

	}

	set kernelSize(value) {

		this.blurPass.kernelSize = value;

	}

	/**
	 * Indicates whether the outlines should be blurred.
	 *
	 * @type {Boolean}
	 * @deprecated Use blurPass.enabled instead.
	 */

	get blur() {

		return this.blurPass.enabled;

	}

	set blur(value) {

		this.blurPass.enabled = value;

	}

	/**
	 * Indicates whether X-ray mode is enabled.
	 *
	 * @type {Boolean}
	 */

	get xRay() {

		return this.defines.has("X_RAY");

	}

	set xRay(value) {

		if(this.xRay !== value) {

			if(value) {

				this.defines.set("X_RAY", "1");

			} else {

				this.defines.delete("X_RAY");

			}

			this.setChanged();

		}

	}

	/**
	 * Indicates whether X-ray mode is enabled.
	 *
	 * @deprecated Use xRay instead.
	 * @return {Boolean} Whether X-ray mode is enabled.
	 */

	isXRayEnabled() {

		return this.xRay;

	}

	/**
	 * Enables or disables X-ray outlines.
	 *
	 * @deprecated Use xRay instead.
	 * @param {Boolean} value - Whether X-ray should be enabled.
	 */

	setXRayEnabled(value) {

		this.xRay = value;

	}

	/**
	 * The pattern texture. Set to `null` to disable.
	 *
	 * @type {Texture}
	 */

	get patternTexture() {

		return this.uniforms.get("patternTexture").value;

	}

	set patternTexture(value) {

		if(value !== null) {

			value.wrapS = value.wrapT = RepeatWrapping;
			this.defines.set("USE_PATTERN", "1");
			this.setVertexShader(vertexShader);

		} else {

			this.defines.delete("USE_PATTERN");
			this.setVertexShader(null);

		}

		this.uniforms.get("patternTexture").value = value;
		this.setChanged();

	}

	/**
	 * Sets the pattern texture.
	 *
	 * @deprecated Use patternTexture instead.
	 * @param {Texture} value - The new texture.
	 */

	setPatternTexture(value) {

		this.patternTexture = value;

	}

	/**
	 * Returns the current resolution scale.
	 *
	 * @return {Number} The resolution scale.
	 * @deprecated Use resolution instead.
	 */

	getResolutionScale() {

		return this.resolution.scale;

	}

	/**
	 * Sets the resolution scale.
	 *
	 * @param {Number} scale - The new resolution scale.
	 * @deprecated Use resolution instead.
	 */

	setResolutionScale(scale) {

		this.resolution.scale = scale;

	}

	/**
	 * Clears the current selection and selects a list of objects.
	 *
	 * @param {Object3D[]} objects - The objects that should be outlined. This array will be copied.
	 * @return {OutlinePass} This pass.
	 * @deprecated Use selection.set() instead.
	 */

	setSelection(objects) {

		this.selection.set(objects);
		return this;

	}

	/**
	 * Clears the list of selected objects.
	 *
	 * @return {OutlinePass} This pass.
	 * @deprecated Use selection.clear() instead.
	 */

	clearSelection() {

		this.selection.clear();
		return this;

	}

	/**
	 * Selects an object.
	 *
	 * @param {Object3D} object - The object that should be outlined.
	 * @return {OutlinePass} This pass.
	 * @deprecated Use selection.add() instead.
	 */

	selectObject(object) {

		this.selection.add(object);
		return this;

	}

	/**
	 * Deselects an object.
	 *
	 * @param {Object3D} object - The object that should no longer be outlined.
	 * @return {OutlinePass} This pass.
	 * @deprecated Use selection.delete() instead.
	 */

	deselectObject(object) {

		this.selection.delete(object);
		return this;

	}

	/**
	 * Updates this effect.
	 *
	 * @param {WebGLRenderer} renderer - The renderer.
	 * @param {WebGLRenderTarget} inputBuffer - A frame buffer that contains the result of the previous pass.
	 * @param {Number} [deltaTime] - The time between the last frame and the current one in seconds.
	 */

	update(renderer, inputBuffer, deltaTime) {

		const scene = this.scene;
		const camera = this.camera;
		const selection = this.selection;
		const uniforms = this.uniforms;
		const pulse = uniforms.get("pulse");

		const background = scene.background;
		const mask = camera.layers.mask;

		if(this.forceUpdate || selection.size > 0) {

			scene.background = null;
			pulse.value = 1;

			if(this.pulseSpeed > 0) {

				pulse.value = Math.cos(this.time * this.pulseSpeed * 10.0) * 0.375 + 0.625;

			}

			this.time += deltaTime;

			// Render a custom depth texture and ignore selected objects.
			selection.setVisible(false);
			this.depthPass.render(renderer);
			selection.setVisible(true);

			// Compare the depth of the selected objects with the depth texture.
			camera.layers.set(selection.layer);
			this.maskPass.render(renderer, this.renderTargetMask);

			// Restore the camera layer mask and the scene background.
			camera.layers.mask = mask;
			scene.background = background;

			// Detect the outline.
			this.outlinePass.render(renderer, null, this.renderTargetOutline);

			if(this.blurPass.enabled) {

				this.blurPass.render(renderer, this.renderTargetOutline, this.renderTargetOutline);

			}

		}

		this.forceUpdate = selection.size > 0;

	}

	/**
	 * Updates the size of internal render targets.
	 *
	 * @param {Number} width - The width.
	 * @param {Number} height - The height.
	 */

	setSize(width, height) {

		this.blurPass.setSize(width, height);
		this.renderTargetMask.setSize(width, height);

		const resolution = this.resolution;
		resolution.setBaseSize(width, height);
		const w = resolution.width, h = resolution.height;

		this.depthPass.setSize(w, h);
		this.renderTargetOutline.setSize(w, h);
		this.outlinePass.fullscreenMaterial.setSize(w, h);

	}

	/**
	 * Performs initialization tasks.
	 *
	 * @param {WebGLRenderer} renderer - The renderer.
	 * @param {Boolean} alpha - Whether the renderer uses the alpha channel or not.
	 * @param {Number} frameBufferType - The type of the main frame buffers.
	 */

	initialize(renderer, alpha, frameBufferType) {

		// No need for high precision: the blur pass operates on a mask texture.
		this.blurPass.initialize(renderer, alpha, UnsignedByteType);

		if(frameBufferType !== undefined) {

			// These passes ignore the buffer type.
			this.depthPass.initialize(renderer, alpha, frameBufferType);
			this.maskPass.initialize(renderer, alpha, frameBufferType);
			this.outlinePass.initialize(renderer, alpha, frameBufferType);

		}

	}

}