import { BufferAttribute, BufferGeometry, Color, DoubleSide, Material, Mesh, MeshBasicMaterial, NearestFilter, SRGBColorSpace, Texture } from "three";
import { serializable, serializeable } from "../engine/engine_serialization_decorator.js";
import { getParam } from "../engine/engine_utils.js";
import { NEEDLE_progressive } from "../engine/extensions/NEEDLE_progressive.js";
import { RGBAColor } from "../engine/js-extensions/index.js";
import { Behaviour } from "./Component.js";
const debug = getParam("debugspriterenderer");
const showWireframe = getParam("wireframe");
class SpriteUtils {
static cache: { [key: string]: BufferGeometry } = {};
static getOrCreateGeometry(sprite: Sprite): BufferGeometry {
if (sprite.__cached_geometry) return sprite.__cached_geometry;
if (sprite.guid) {
if (SpriteUtils.cache[sprite.guid]) {
if (debug) console.log("Take cached geometry for sprite", sprite.guid);
return SpriteUtils.cache[sprite.guid];
}
}
const geo = new BufferGeometry();
sprite.__cached_geometry = geo;
const vertices = new Float32Array(sprite.triangles.length * 3);
const uvs = new Float32Array(sprite.triangles.length * 2);
for (let i = 0; i < sprite.triangles.length; i += 1) {
const index = sprite.triangles[i];
vertices[i * 3] = -sprite.vertices[index].x;
vertices[i * 3 + 1] = sprite.vertices[index].y;
vertices[i * 3 + 2] = 0;
const uv = sprite.uv[index];
uvs[i * 2] = uv.x;
uvs[i * 2 + 1] = 1 - uv.y;
}
geo.setAttribute("position", new BufferAttribute(vertices, 3));
geo.setAttribute("uv", new BufferAttribute(uvs, 2));
if (sprite.guid)
this.cache[sprite.guid] = geo;
if (debug)
console.log("Built sprite geometry", sprite, geo);
return geo;
}
}
///
/// SpriteRenderer draw mode.
///
export enum SpriteDrawMode {
///
/// Displays the full sprite.
///
Simple = 0,
///
/// The SpriteRenderer will render the sprite as a 9-slice image where the corners will remain constant and the other sections will scale.
///
Sliced = 1,
///
/// The SpriteRenderer will render the sprite as a 9-slice image where the corners will remain constant and the other sections will tile.
///
Tiled = 2,
}
class Vec2 {
x!: number;
y!: number;
}
function updateTextureIfNecessary(tex: Texture) {
if (!tex) return;
if (tex.colorSpace != SRGBColorSpace) {
tex.colorSpace = SRGBColorSpace;
tex.needsUpdate = true;
}
if (tex.minFilter == NearestFilter && tex.magFilter == NearestFilter) {
tex.anisotropy = 1;
tex.needsUpdate = true;
}
}
/**
* A sprite is a mesh that represents a 2D image
* @category Rendering
* @group Components
*/
export class Sprite {
constructor(texture?: Texture) {
if (texture) {
this.texture = texture;
this.triangles = [0, 1, 2, 0, 2, 3];
this.uv = [{ x: 0, y: 0 }, { x: 1, y: 0 }, { x: 1, y: 1 }, { x: 0, y: 1 }];
this.vertices = [{ x: -.5, y: -.5 }, { x: .5, y: -.5 }, { x: .5, y: .5 }, { x: -.5, y: .5 }];
}
}
@serializable()
guid?: string;
@serializable(Texture)
texture?: Texture;
@serializeable()
triangles!: Array;
@serializeable()
uv!: Array;
@serializeable()
vertices!: Array;
/** @internal */
__cached_geometry?: BufferGeometry;
/**
* The mesh that represents the sprite
*/
get mesh(): Mesh {
if (!this._mesh) {
this._mesh = new Mesh(SpriteUtils.getOrCreateGeometry(this), this.material);
}
return this._mesh;
}
private _mesh: Mesh | undefined;
/**
* The material used to render the sprite
*/
get material() {
if (!this._material) {
if (this.texture) {
updateTextureIfNecessary(this.texture);
}
this._material = new MeshBasicMaterial({
map: this.texture,
color: 0xffffff,
side: DoubleSide,
transparent: true
});
}
return this._material;
}
private _material: MeshBasicMaterial | undefined;
/**
* The geometry of the sprite that can be used to create a mesh
*/
getGeometry() {
return SpriteUtils.getOrCreateGeometry(this);
}
}
const $spriteTexOwner = Symbol("spriteOwner");
export class SpriteSheet {
@serializable(Sprite)
sprites: Sprite[];
constructor() {
this.sprites = [];
}
}
export class SpriteData {
static create() {
const i = new SpriteData();
i.spriteSheet = new SpriteSheet();
return i;
}
// we don't assign anything here because it's used by the serialization system.
// there's currently a limitation in the serializer when e.g. spriteSheet is already assigned it will not be overriden by the serializer
// hence the spriteSheet field is undefined by default
constructor() { }
clone() {
const i = new SpriteData();
i.index = this.index;
i.spriteSheet = this.spriteSheet;
return i;
}
/**
* Set the sprite to be rendered in the currently assigned sprite sheet at the currently active index {@link index}
*/
set sprite(sprite: Sprite | undefined) {
if (!sprite) {
return;
}
if (!this.spriteSheet) {
this.spriteSheet = new SpriteSheet();
this.spriteSheet.sprites = [sprite];
this.index = 0;
}
else {
if (this.index === null || this.index === undefined) this.index = 0;
this.spriteSheet.sprites[this.index] = sprite;
}
}
/** The currently active sprite */
get sprite(): Sprite | undefined {
if (!this.spriteSheet) return undefined;
return this.spriteSheet.sprites[this.index];
}
/**
* The spritesheet holds all sprites that can be rendered by the sprite renderer
*/
@serializable(SpriteSheet)
spriteSheet?: SpriteSheet;
/**
* The index of the sprite to be rendered in the currently assigned sprite sheet
*/
@serializable()
index: number = 0;
update(material: Material | undefined) {
if (!this.spriteSheet) return;
const index = this.index;
if (index < 0 || index >= this.spriteSheet.sprites.length)
return;
const sprite = this.spriteSheet.sprites[index];
const tex = sprite?.texture;
if (!tex) return;
updateTextureIfNecessary(tex);
if (!sprite["__hasLoadedProgressive"]) {
sprite["__hasLoadedProgressive"] = true;
const previousTexture = tex;
NEEDLE_progressive.assignTextureLOD(tex, 0).then(res => {
if (res instanceof Texture) {
sprite.texture = res;
const shouldUpdateInMaterial = material?.["map"] === previousTexture;
if (shouldUpdateInMaterial) {
material["map"] = res;
material.needsUpdate = true;
}
}
});
}
}
}
/**
* The sprite renderer renders a sprite on a GameObject using an assigned spritesheet ({@link SpriteData}).
*/
export class SpriteRenderer extends Behaviour {
/** @internal The draw mode of the sprite renderer */
@serializable()
drawMode: SpriteDrawMode = SpriteDrawMode.Simple;
/** @internal Used when drawMode is set to Tiled */
@serializable(Vec2)
size: Vec2 = { x: 1, y: 1 };
@serializable(RGBAColor)
color?: RGBAColor;
/**
* The material that is used to render the sprite
*/
@serializable(Material)
sharedMaterial?: Material;
// additional data
@serializable()
transparent: boolean = true;
@serializable()
cutoutThreshold: number = 0;
@serializable()
castShadows: boolean = false;
@serializable()
renderOrder: number = 0;
@serializable()
toneMapped: boolean = true;
/**
* Assign a new texture to the currently active sprite
*/
set texture(value: Texture | undefined) {
if (!this._spriteSheet) return;
const currentSprite = this._spriteSheet.spriteSheet?.sprites[this.spriteIndex];
if (!currentSprite) return;
currentSprite.texture = value;
this.updateSprite();
}
/**
* Add a new sprite to the currently assigned sprite sheet. The sprite will be added to the end of the sprite sheet.
* Note that the sprite will not be rendered by default - set the `spriteIndex` to the index of the sprite to be rendered.
* @param sprite The sprite to be added
* @returns The index of the sprite in the sprite sheet
* @example
* ```typescript
* const spriteRenderer = gameObject.addComponent(SpriteRenderer);
* const index = spriteRenderer.addSprite(mySprite);
* if(index >= 0)
* spriteRenderer.spriteIndex = index;
* ```
*/
addSprite(sprite: Sprite, setActive: boolean = false): number {
if (!this._spriteSheet) {
this._spriteSheet = SpriteData.create();
}
if (!this._spriteSheet.spriteSheet) return -1;
this._spriteSheet.spriteSheet?.sprites.push(sprite);
const index = this._spriteSheet.spriteSheet?.sprites.length - 1;
if (setActive) {
this.spriteIndex = index;
}
return index;
}
/**
* Get the currently active sprite
*/
@serializable(SpriteData)
get sprite(): SpriteData | undefined {
return this._spriteSheet;
}
/**
* Set the sprite to be rendered in the currently assigned sprite sheet at the currently active index {@link spriteIndex}
*/
set sprite(value: Sprite | SpriteData | undefined | number) {
if (value === this._spriteSheet) return;
if (typeof value === "number") {
const index = Math.floor(value);
this.spriteIndex = index;
}
else if (value instanceof Sprite) {
if (!this._spriteSheet) {
this._spriteSheet = SpriteData.create();
}
if (this._spriteSheet.sprite != value) {
this._spriteSheet.sprite = value;
}
this.updateSprite();
}
else if (value != this._spriteSheet) {
this._spriteSheet = value;
this.updateSprite();
}
}
/**
* Set the index of the sprite to be rendered in the currently assigned sprite sheet
*/
set spriteIndex(value: number) {
if (!this._spriteSheet) return;
this._spriteSheet.index = value;
this.updateSprite();
}
get spriteIndex(): number {
return this._spriteSheet?.index ?? 0;
}
/**
* Get the number of sprites in the currently assigned sprite sheet
*/
get spriteFrames(): number {
return this._spriteSheet?.spriteSheet?.sprites.length ?? 0;
}
private _spriteSheet?: SpriteData;
private _currentSprite?: Mesh;
/** @internal */
awake(): void {
this._currentSprite = undefined;
if (!this._spriteSheet) {
this._spriteSheet = SpriteData.create();
}
else {
// Ensure each SpriteRenderer has a unique spritesheet instance for cases where sprite renderer are cloned at runtime and then different sprites are assigned to each instance
this._spriteSheet = this._spriteSheet.clone();
}
if (debug) {
console.log("Awake", this.name, this, this.sprite);
}
}
/** @internal */
start() {
if (!this._currentSprite)
this.updateSprite();
else if (this.gameObject)
this.gameObject.add(this._currentSprite);
}
/**
* Update the sprite. Modified properties will be applied to the sprite mesh. This method is called automatically when the sprite is changed.
* @param force If true, the sprite will be forced to update.
* @returns True if the sprite was updated successfully
*/
updateSprite(force: boolean = false): boolean {
if (!this.__didAwake && !force) return false;
const data = this._spriteSheet;
if (!data?.spriteSheet?.sprites) {
console.warn("SpriteRenderer has no data or spritesheet assigned...");
return false;
}
const sprite = data.spriteSheet.sprites[this.spriteIndex];
if (!sprite) {
if (debug)
console.warn("Sprite not found", this.spriteIndex, data.spriteSheet.sprites);
return false;
}
if (!this._currentSprite) {
const mat = new MeshBasicMaterial({ color: 0xffffff, side: DoubleSide });
if (showWireframe)
mat.wireframe = true;
if (this.color) {
if (!mat["color"]) mat["color"] = new Color();
mat["color"].copy(this.color);
mat["opacity"] = this.color.alpha;
}
mat.transparent = true;
mat.toneMapped = this.toneMapped;
mat.depthWrite = false;
if (sprite.texture && !mat.wireframe) {
let tex = sprite.texture;
// the sprite renderer modifies the texture offset and scale
// so we need to clone the texture
// if the same texture is used multiple times
if (tex[$spriteTexOwner] !== undefined && tex[$spriteTexOwner] !== this && this.spriteFrames > 1) {
tex = sprite!.texture = tex.clone();
}
tex[$spriteTexOwner] = this;
mat["map"] = tex;
}
this.sharedMaterial = mat;
this._currentSprite = new Mesh(SpriteUtils.getOrCreateGeometry(sprite), mat);
this._currentSprite.renderOrder = Math.round(this.renderOrder);
NEEDLE_progressive.assignTextureLOD(mat, 0);
}
else {
this._currentSprite.geometry = SpriteUtils.getOrCreateGeometry(sprite);
this._currentSprite.material["map"] = sprite.texture;
}
if (this._currentSprite.parent !== this.gameObject) {
if (this.drawMode === SpriteDrawMode.Tiled)
this._currentSprite.scale.set(this.size.x, this.size.y, 1);
if (this.gameObject)
this.gameObject.add(this._currentSprite);
}
if (this._currentSprite) {
this._currentSprite.layers.set(this.layer)
}
if (this.sharedMaterial) {
this.sharedMaterial.alphaTest = this.cutoutThreshold;
this.sharedMaterial.transparent = this.transparent;
}
this._currentSprite.castShadow = this.castShadows;
data?.update(this.sharedMaterial);
return true;
}
}