import { Color } from '../../../../color/Color';
import { DOMAdapter } from '../../../../environment/adapter';
import { Matrix } from '../../../../maths/matrix/Matrix';
import { type WRAP_MODE } from '../../../../rendering/renderers/shared/texture/const';
import { ImageSource } from '../../../../rendering/renderers/shared/texture/sources/ImageSource';
import { Texture } from '../../../../rendering/renderers/shared/texture/Texture';
import { uid } from '../../../../utils/data/uid';
import { deprecation } from '../../../../utils/logging/deprecation';
import { definedProps } from '../../../container/utils/definedProps';
import type { ColorSource } from '../../../../color/Color';
import type { PointData } from '../../../../maths/point/PointData';
import type { CanvasAndContext } from '../../../../rendering/renderers/shared/texture/CanvasPool';
import type { TextureSpace } from '../FillTypes';
export type GradientType = 'linear' | 'radial';
/**
* Represents the style options for a linear gradient fill.
* @memberof scene
*/
export interface BaseGradientOptions
{
/** The type of gradient */
type?: GradientType;
/** Array of colors stops to use in the gradient */
colorStops?: { offset: number, color: ColorSource }[];
/** Whether coordinates are 'global' or 'local' */
textureSpace?: TextureSpace;
/**
* The size of the texture to use for the gradient - this is for advanced usage.
* The texture size does not need to match the size of the object being drawn.
* Due to GPU interpolation, gradient textures can be relatively small!
* Consider using a larger texture size if your gradient has a lot of very tight color steps
*/
textureSize?: number;
/**
* The wrap mode of the gradient.
* This can be 'clamp-to-edge' or 'repeat'.
* @default 'clamp-to-edge'
*/
wrapMode?: WRAP_MODE
}
/**
* Options specific to linear gradients.
* A linear gradient creates a smooth transition between colors along a straight line defined by start and end points.
* @memberof scene
*/
export interface LinearGradientOptions extends BaseGradientOptions
{
/** The type of gradient. Must be 'linear' for linear gradients. */
type?: 'linear';
/**
* The start point of the gradient.
* This point defines where the gradient begins.
* It is represented as a PointData object containing x and y coordinates.
* The coordinates are in local space by default (0-1), but can be in global space if specified.
*/
start?: PointData;
/**
* The end point of the gradient.
* This point defines where the gradient ends.
* It is represented as a PointData object containing x and y coordinates.
* The coordinates are in local space by default (0-1), but can be in global space if specified.
*/
end?: PointData;
}
/**
* Options specific to radial gradients.
* A radial gradient creates a smooth transition between colors that radiates outward in a circular pattern.
* The gradient is defined by inner and outer circles, each with their own radius.
* @memberof scene
*/
export interface RadialGradientOptions extends BaseGradientOptions
{
/** The type of gradient. Must be 'radial' for radial gradients. */
type?: 'radial';
/** The center point of the inner circle where the gradient begins. In local coordinates by default (0-1). */
center?: PointData;
/** The radius of the inner circle where the gradient begins. */
innerRadius?: number;
/** The center point of the outer circle where the gradient ends. In local coordinates by default (0-1). */
outerCenter?: PointData;
/** The radius of the outer circle where the gradient ends. */
outerRadius?: number;
/**
* The y scale of the gradient, use this to make the gradient elliptical.
* NOTE: Only applied to radial gradients used with Graphics.
*/
scale?: number;
/**
* The rotation of the gradient in radians, useful for making the gradient elliptical.
* NOTE: Only applied to radial gradients used with Graphics.
*/
rotation?: number;
}
/**
* Options for creating a gradient fill.
* @memberof scene
*/
export type GradientOptions = LinearGradientOptions | RadialGradientOptions;
/**
* If no color stops are provided, we use a default gradient of white to black - this is to avoid a blank gradient if a dev
* forgets to set them.
*/
const emptyColorStops: { offset: number, color: string }[] = [{ offset: 0, color: 'white' }, { offset: 1, color: 'black' }];
/**
* Class representing a gradient fill that can be used to fill shapes and text.
* Supports both linear and radial gradients with multiple color stops.
*
* For linear gradients, color stops define colors and positions (0 to 1) along a line from start point (x0,y0)
* to end point (x1,y1).
*
* For radial gradients, color stops define colors between two circles - an inner circle centered at (x0,y0) with radius r0,
* and an outer circle centered at (x1,y1) with radius r1.
* @example
* ```ts
* // Create a vertical linear gradient from red to blue
* const linearGradient = new FillGradient({
* type: 'linear',
* start: { x: 0, y: 0 }, // Start at top
* end: { x: 0, y: 1 }, // End at bottom
* colorStops: [
* { offset: 0, color: 'red' }, // Red at start
* { offset: 1, color: 'blue' } // Blue at end
* ],
* // Use normalized coordinate system where (0,0) is the top-left and (1,1) is the bottom-right of the shape
* textureSpace: 'local'
* });
*
* // Create a radial gradient from yellow center to green edge
* const radialGradient = new FillGradient({
* type: 'radial',
* center: { x: 0.5, y: 0.5 },
* innerRadius: 0,
* outerCenter: { x: 0.5, y: 0.5 },
* outerRadius: 0.5,
* colorStops: [
* { offset: 0, color: 'yellow' }, // Center color
* { offset: 1, color: 'green' } // Edge color
* ],
* // Use normalized coordinate system where (0,0) is the top-left and (1,1) is the bottom-right of the shape
* textureSpace: 'local'
* });
*
* // Create a rainbow linear gradient in global coordinates
* const globalGradient = new FillGradient({
* type: 'linear',
* start: { x: 0, y: 0 },
* end: { x: 100, y: 0 },
* colorStops: [
* { offset: 0, color: 0xff0000 }, // Red
* { offset: 0.33, color: 0x00ff00 }, // Green
* { offset: 0.66, color: 0x0000ff }, // Blue
* { offset: 1, color: 0xff00ff } // Purple
* ],
* textureSpace: 'global' // Use world coordinates
* });
*
* // Create an offset radial gradient
* const offsetRadial = new FillGradient({
* type: 'radial',
* center: { x: 0.3, y: 0.3 },
* innerRadius: 0.1,
* outerCenter: { x: 0.5, y: 0.5 },
* outerRadius: 0.5,
* colorStops: [
* { offset: 0, color: 'white' },
* { offset: 1, color: 'black' }
* ],
* // Use normalized coordinate system where (0,0) is the top-left and (1,1) is the bottom-right of the shape
* textureSpace: 'local'
* });
* ```
*
* Internally this creates a texture of the gradient then applies a
* transform to it to give it the correct size and angle.
*
* This means that it's important to destroy a gradient when it is no longer needed
* to avoid memory leaks.
*
* If you want to animate a gradient then it's best to modify and update an existing one
* rather than creating a whole new one each time. That or use a custom shader.
* @memberof scene
* @implements {CanvasGradient}
*/
export class FillGradient implements CanvasGradient
{
/**
* Default options for creating a gradient fill
* @property {PointData} start - Start point of the gradient (default: { x: 0, y: 0 })
* @property {PointData} end - End point of the gradient (default: { x: 0, y: 1 })
* @property {TextureSpace} textureSpace - Whether coordinates are 'global' or 'local' (default: 'local')
* @property {number} textureSize - The size of the texture to use for the gradient (default: 256)
* @property {Array<{offset: number, color: ColorSource}>} colorStops - Array of color stops (default: empty array)
* @property {GradientType} type - Type of gradient (default: 'linear')
* @property {WRAP_MODE} wrapMode - The wrap mode of the gradient (default: 'clamp-to-edge')
*/
public static readonly defaultLinearOptions: LinearGradientOptions = {
start: { x: 0, y: 0 },
end: { x: 0, y: 1 },
colorStops: [],
textureSpace: 'local',
type: 'linear',
textureSize: 256,
wrapMode: 'clamp-to-edge'
};
/**
* Default options for creating a radial gradient fill
* @property {PointData} innerCenter - Center of the inner circle (default: { x: 0.5, y: 0.5 })
* @property {number} innerRadius - Radius of the inner circle (default: 0)
* @property {PointData} outerCenter - Center of the outer circle (default: { x: 0.5, y: 0.5 })
* @property {number} outerRadius - Radius of the outer circle (default: 0.5)
* @property {TextureSpace} textureSpace - Whether coordinates are 'global' or 'local' (default: 'local')
* @property {number} textureSize - The size of the texture to use for the gradient (default: 256)
* @property {Array<{offset: number, color: ColorSource}>} colorStops - Array of color stops (default: empty array)
* @property {GradientType} type - Type of gradient (default: 'radial')
* @property {WRAP_MODE} wrapMode - The wrap mode of the gradient (default: 'clamp-to-edge')
*/
public static readonly defaultRadialOptions: RadialGradientOptions = {
center: { x: 0.5, y: 0.5 },
innerRadius: 0,
outerRadius: 0.5,
colorStops: [],
scale: 1,
textureSpace: 'local',
type: 'radial',
textureSize: 256,
wrapMode: 'clamp-to-edge'
};
/** Unique identifier for this gradient instance */
public readonly uid: number = uid('fillGradient');
/** Type of gradient - currently only supports 'linear' */
public readonly type: GradientType = 'linear';
/** Internal texture used to render the gradient */
public texture: Texture;
/** Transform matrix for positioning the gradient */
public transform: Matrix;
/** Array of color stops defining the gradient */
public colorStops: Array<{ offset: number, color: string }> = [];
/** Whether gradient coordinates are in local or global space */
public textureSpace: TextureSpace;
private readonly _textureSize: number;
/** The start point of the linear gradient */
public start: PointData;
/** The end point of the linear gradient */
public end: PointData;
/** The wrap mode of the gradient texture */
private readonly _wrapMode: WRAP_MODE;
/** The center point of the inner circle of the radial gradient */
public center: PointData;
/** The center point of the outer circle of the radial gradient */
public outerCenter: PointData;
/** The radius of the inner circle of the radial gradient */
public innerRadius: number;
/** The radius of the outer circle of the radial gradient */
public outerRadius: number;
/** The scale of the radial gradient */
public scale: number;
/** The rotation of the radial gradient */
public rotation: number;
/**
* Creates a new gradient fill. The constructor behavior changes based on the gradient type.
*
* For linear gradients:
* @param {GradientOptions} options - The options for the gradient
* @param {PointData} [options.start] - The start point of the linear gradient
* @param {PointData} [options.end] - The end point of the linear gradient
*
* For radial gradients:
* @param {PointData} [options.innerCenter] - The center point of the inner circle of the radial gradient
* @param {number} [options.innerRadius] - The radius of the inner circle of the radial gradient
* @param {PointData} [options.outerCenter] - The center point of the outer circle of the radial gradient
* @param {number} [options.outerRadius] - The radius of the outer circle of the radial gradient
*
* Common options for both gradient types:
* @param {TextureSpace} [options.textureSpace='local'] - Whether coordinates are 'global' or 'local'
* @param {number} [options.textureSize=256] - The size of the texture to use for the gradient
* @param {Array<{offset: number, color: ColorSource}>} [options.colorStops=[]] - Array of color stops
* @param {GradientType} [options.type='linear'] - Type of gradient
*/
constructor(options: GradientOptions);
/** @deprecated since 8.5.2 */
constructor(
x0?: number,
y0?: number,
x1?: number,
y1?: number,
textureSpace?: TextureSpace,
textureSize?: number
);
constructor(...args: [GradientOptions] | [number?, number?, number?, number?, TextureSpace?, number?])
{
let options = ensureGradientOptions(args);
const defaults = options.type === 'radial' ? FillGradient.defaultRadialOptions : FillGradient.defaultLinearOptions;
options = { ...defaults, ...definedProps(options) };
this._textureSize = options.textureSize;
this._wrapMode = options.wrapMode;
if (options.type === 'radial')
{
this.center = options.center;
this.outerCenter = options.outerCenter ?? this.center;
this.innerRadius = options.innerRadius;
this.outerRadius = options.outerRadius;
this.scale = options.scale;
this.rotation = options.rotation;
}
else
{
this.start = options.start;
this.end = options.end;
}
this.textureSpace = options.textureSpace;
this.type = options.type;
options.colorStops.forEach((stop) =>
{
this.addColorStop(stop.offset, stop.color);
});
}
/**
* Adds a color stop to the gradient
* @param offset - Position of the stop (0-1)
* @param color - Color of the stop
* @returns This gradient instance for chaining
*/
public addColorStop(offset: number, color: ColorSource): this
{
this.colorStops.push({ offset, color: Color.shared.setValue(color).toHexa() });
return this;
}
/**
* Builds the internal texture and transform for the gradient.
* Called automatically when the gradient is first used.
* @internal
*/
public buildLinearGradient(): void
{
if (this.texture) return;
let { x: x0, y: y0 } = this.start;
let { x: x1, y: y1 } = this.end;
let dx = x1 - x0;
let dy = y1 - y0;
// Determine flip based on original dx/dy and swap coordinates if necessary
const flip = dx < 0 || dy < 0;
if (this._wrapMode === 'clamp-to-edge')
{
if (dx < 0)
{
const temp = x0;
x0 = x1;
x1 = temp;
dx *= -1;
}
if (dy < 0)
{
const temp = y0;
y0 = y1;
y1 = temp;
dy *= -1;
}
}
const colorStops = this.colorStops.length ? this.colorStops : emptyColorStops;
const defaultSize = this._textureSize;
const { canvas, context } = getCanvas(defaultSize, 1);
const gradient = !flip
? context.createLinearGradient(0, 0, this._textureSize, 0)
: context.createLinearGradient(this._textureSize, 0, 0, 0);
addColorStops(gradient, colorStops);
context.fillStyle = gradient;
context.fillRect(0, 0, defaultSize, 1);
this.texture = new Texture({
source: new ImageSource({
resource: canvas,
addressMode: this._wrapMode,
}),
});
// generate some UVS based on the gradient direction sent
const dist = Math.sqrt((dx * dx) + (dy * dy));
const angle = Math.atan2(dy, dx);
// little offset to stop the uvs from flowing over the edge..
// this matrix is inverted when used in the graphics
// add a tiny off set to prevent uv bleeding..
const m = new Matrix();
m.scale((dist / defaultSize), 1);
m.rotate(angle);
m.translate(x0, y0);
if (this.textureSpace === 'local')
{
m.scale(defaultSize, defaultSize);
}
this.transform = m;
}
public buildGradient(): void
{
if (this.type === 'linear')
{
this.buildLinearGradient();
}
else
{
this.buildRadialGradient();
}
}
public buildRadialGradient(): void
{
if (this.texture) return;
const colorStops = this.colorStops.length ? this.colorStops : emptyColorStops;
const defaultSize = this._textureSize;
const { canvas, context } = getCanvas(defaultSize, defaultSize);
const { x: x0, y: y0 } = this.center;
const { x: x1, y: y1 } = this.outerCenter;
const r0 = this.innerRadius;
const r1 = this.outerRadius;
const ox = x1 - r1;
const oy = y1 - r1;
const scale = defaultSize / (r1 * 2);
const cx = (x0 - ox) * scale;
const cy = (y0 - oy) * scale;
const gradient = context.createRadialGradient(
cx,
cy,
r0 * scale,
(x1 - ox) * scale,
(y1 - oy) * scale,
r1 * scale
);
addColorStops(gradient, colorStops);
context.fillStyle = colorStops[colorStops.length - 1].color;
context.fillRect(0, 0, defaultSize, defaultSize);
context.fillStyle = gradient;
// First translate to center
context.translate(cx, cy);
// Then apply rotation
context.rotate(this.rotation);
// Then scale2
context.scale(1, this.scale);
// Finally translate back, taking scale into account
context.translate(-cx, -cy);
context.fillRect(0, 0, defaultSize, defaultSize);
this.texture = new Texture({
source: new ImageSource({
resource: canvas,
addressMode: this._wrapMode,
}),
});
const m = new Matrix();
// this matrix is inverted when used in the graphics
m.scale(1 / scale, 1 / scale);
m.translate(ox, oy);
if (this.textureSpace === 'local')
{
m.scale(defaultSize, defaultSize);
}
this.transform = m;
}
/**
* Gets a unique key representing the current state of the gradient.
* Used internally for caching.
* @returns Unique string key
*/
public get styleKey(): number
{
return this.uid;
}
public destroy(): void
{
this.texture?.destroy(true);
this.texture = null;
}
}
function addColorStops(gradient: CanvasGradient, colorStops: { offset: number, color: string }[]): void
{
for (let i = 0; i < colorStops.length; i++)
{
const stop = colorStops[i];
gradient.addColorStop(stop.offset, stop.color);
}
}
function getCanvas(width: number, height: number): CanvasAndContext
{
const canvas = DOMAdapter.get().createCanvas(width, height);
const context = canvas.getContext('2d');
return { canvas, context };
}
/**
* Helper function to ensure consistent handling of gradient options.
* This function handles both the new options object format and the deprecated parameter format.
* @example
* // New recommended way:
* const options = ensureGradientOptions({
* start: { x: 0, y: 0 },
* end: { x: 100, y: 100 },
* textureSpace: 'local'
* });
*
* // Deprecated way (will show warning in debug):
* const options = ensureGradientOptions([0, 0, 100, 100, 'local']);
* @param args - Arguments passed to gradient constructor
* @returns Normalized gradient options object
* @internal
*/
function ensureGradientOptions(
args: any[],
): GradientOptions
{
let options = (args[0] ?? {}) as GradientOptions;
// @deprecated
if (typeof options === 'number' || args[1])
{
// #if _DEBUG
deprecation('8.5.2', `use options object instead`);
// #endif
options = {
type: 'linear',
start: { x: args[0], y: args[1] },
end: { x: args[2], y: args[3] },
textureSpace: args[4] as 'global' | 'local',
textureSize: args[5] ?? FillGradient.defaultLinearOptions.textureSize
};
}
return options;
}