From e19ef8ac674c7fc35eec1ac2a1f7d707ccb26afe Mon Sep 17 00:00:00 2001 From: keasy9 <127016754+keasy9@users.noreply.github.com> Date: Mon, 12 Jan 2026 21:50:37 +0700 Subject: [PATCH] feat: [#3649] Add rgb and lrgb color lerp modes (#3658) === :clipboard: PR Checklist :clipboard: === - [x] :pushpin: issue exists in github for these changes - [x] :microscope: existing tests still pass - [x] :see_no_evil: code conforms to the [style guide](https://github.com/excaliburjs/Excalibur/blob/main/STYLEGUIDE.md) - [x] :triangular_ruler: new tests written and passing / old tests updated with new scenario(s) - [x] :page_facing_up: changelog entry added (or not needed) ================== Closes #3649 ## Changes: - `Color.lerp` rename to `Color.lerpHSL` - add `Color.lerpRGB` method, which uses linear interpolation for blending color - add `Color.lerpLRGB` method, which uses linear interpolation with gamma correction - add `Color.lerp` again, now as alias for other lerp methods with optional `colorSpace` param --- CHANGELOG.md | 15 +++++- site/docs/04-graphics/04.1-color.mdx | 14 +++-- site/docs/04-graphics/LerpedColor.png | Bin 1126 -> 2252 bytes src/engine/color.ts | 73 ++++++++++++++++++++++++-- src/spec/vitest/color-spec.ts | 12 ++++- 5 files changed, 105 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a54fb5c..62cb17a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,20 @@ This project adheres to [Semantic Versioning](http://semver.org/). ### Added -- +- Added new lerp modes for color which can be chosen by aptional parameter in `Color.lerp` or by calling different methods: + ```typescript + Color.lerp(colorA, colorB, t); // 'hsl' by default + // eqivalent to: + Color.lerpHSL(colorA, colorB, t); + + Color.lerp(colorA, colorB, t, 'rgb'); + // eqivalent to: + Color.lerpRGB(colorA, colorB, t); + + Color.lerp(colorA, colorB, t, 'lrgb'); + // equivalent to: + Color.lerpLRGB(colorA, colorB, t); + ``` ### Fixed diff --git a/site/docs/04-graphics/04.1-color.mdx b/site/docs/04-graphics/04.1-color.mdx index e8dd03c8..94c40213 100644 --- a/site/docs/04-graphics/04.1-color.mdx +++ b/site/docs/04-graphics/04.1-color.mdx @@ -72,13 +72,17 @@ const rndmColor = ex.Color.random(); ## Lerping Colors -The [[Color.lerp]] static method linearly interpolates between two colors. - -The interpolation is performed in **HSL color space** for smooth hue transitions, then converted back to RGB. +The [[Color.lerp]] static method linearly interpolates between two colors. - `start` — The starting `Color` - `end` — The target `Color` - `t` — The interpolation factor, between `0` (start) and `1` (end) +- `colorSpace` — The interpolation mode: `hsl` (default), `rgb` or `lrgb` + +Difference between interpolation modes: +- HSL — performing interpolation in **HSL color space** for smooth hue transitions, then converted back to RGB. Balanced for speed and gradient realism; +- RGB — linear interpolation for all **R**, **G**, **B** and **A** components separately, fastest mode for now; +- LRGB — applying **alpha correcting** to **R**, **G** and **B** components, then interpolating it and **A** linear for more realistic gradient at the cost of speed. **Returns:** A new `Color` representing the interpolated result. @@ -87,7 +91,9 @@ The interpolation is performed in **HSL color space** for smooth hue transitions ```ts twoslash // @include: ex // ---cut--- -const mid = ex.Color.lerp(ex.Color.Red, ex.Color.Blue, 0.5); // A smooth purple blend +const midHSL = ex.Color.lerp(ex.Color.Red, ex.Color.Blue, 0.5); // A smooth purple blend, keeps brightness from source colors +const midRGB = ex.Color.lerp(ex.Color.Red, ex.Color.Blue, 0.5, 'rgb'); // A fast purple blend, darker than real +const midLRGB = ex.Color.lerp(ex.Color.Red, ex.Color.Blue, 0.5, 'lrgb'); // A realistic purple blend ``` ![Lerped Color - Red/Blue](./LerpedColor.png) diff --git a/site/docs/04-graphics/LerpedColor.png b/site/docs/04-graphics/LerpedColor.png index ebd7943cff0b3cf8ac903159a152541949ccfb2c..cf15e8adeb56bd41dd140246a2aa6ca20fb2d6c1 100644 GIT binary patch literal 2252 zcmeAS@N?(olHy`uVBq!ia0y~yU~FSxV0g&E#=yXEhD~TQ0|NtNage(c!@6@aFBupZ zSkfJR9T^xl_H+M9WMyDr;4JWnEM{QfI}E~%$MaXDFfec=db&74MC&;Rbtd~(V`ws95NKg+P~c%;Nn}vqU}kV6U8*GQ+4^-` z*z?Gt*S$-ztzVJ#^%r|-*2A#J=FP`T@2)hzU$NXTUi!_& z`kcElYZp(qlls%7n`M7JH~z}&=lNG!zZHCzG2xxtUu|z!E4{z(+FXmEZh!d|FTYjX ze|!G*(d+ltw|=XTUDK^^6Xfsv^u>kOpXT-N|Ni61hPQ5eC%##|d|uriy?Gsu@4Ekf z{WCN9Yf%V$x=0lB;P+$9R z9&5LJzkg!IAx)pCf|LyzI9eV4}OZ|SnV)x#Pzkd1i|9<^beE50Riy!$lajWfBEuz zHIMbqf4vf7dvksQ$Xm<5mDlh4TeB2up^{alH_w8@z zJnXmgZ|dC@t5!vqR_*-m{{3zKtlm4}|5x(om)HOLS6-FRx0&DM(A?<_Vq*7R?pw$c z>$j^r`~RBlw~wc%$Nb)8w{F#{+OLP-m$pCa^DSkzeYziKZDW*akj;q^v;J}ohp4dvh(%y@7FuoUw7V9ZR@vxz0Y#bpT+NZ ziogC^u6&(UFD6P#pX;j4zqwVvk4fa)-P!$h!Tpu|_A6IeMflt8S-)!4wCV19*X3P( zd41ZkZl|X$5BtlbnQuDl*yU$jsXDmf+nSU)uD;#pZ>RPYZ?Rh}qZ734{LQ<$b$@up zLvHRn`aEl2+%cQ9%gc_-@AFJq|EV*PJ!Z$1J^$_$XH;c#C+2iNJ!)vT@~hb5@L9LT z&RzW&EH=M9EvUk$sNPL#U>FhdGWvZht~fM|GxYs^Zl%z{`4RD3+j^(zBroo?rK6|#RsYE z_}}sy>rA!@cAx(%E$XHh_TKi;hP8axj*9QvJmu&y`S5zhp5Mu}f;-DkKYHrcqyJ}V zx$=R9>};F=xE;Cv-cPbyn!7CO^VZ@&A)lwckN*05f2lgdWqx~$ig&+XSFYte^vrJ6 z{V(74Pxm~0z1JXT^|78W8{66PZ8r1w?m0heZt=sQ|6x~GJw7fyoqb)6)r_b7wf`R! z#J;=#&th70>Bk>o-_l>_U43c)MgR0O-TU0!|0Cvul^mH^^>X98M_(%Vu5XUM_5bzR zN1xlw;vPu-Jo~&?Y=_kLtGzJ`@-}JMF5^9S@y*7lmwPYv*Y;Jh{m$R_BWCfx+a@{x zWc2j)t{Tt(a{lxOr4M)RJpP*XzSnx*>tmMtW`F&A+~U31g55PqH}bCETOa&7eofYn zox6T2Y21E&s`r@cQh6Rd=P!D)y)nwWc1F3M&wprk{F}6>byDKP_r2a;z5Z3t7aB^zrC249r}LnqdmTp zX8trhH~aeSBQq_xhg{9PJXLcb~0-DtK-k7 z-8y{u+O=2V>;J5?J*D^S?H~Qy&dPs(y-|OwSa$Jc@#U@8UT$eGKYOGm>BSATEa~vy zh1>4lJ$AKjPHy>jPh-PGh6E#u97Vlq0f#eM&Yk&lb?dhWc@dVsU8*}`H(h&qW%2WS zS^WGj*Q~udKfdsu+R4SA>$CWKzr4FSe_Q(vS9{CvVYOFh&Z+$MP80eIq_U~0xwLrt()9Tczc2SH+t;hfH Zvo4)g^kO~NeFg>w22WQ%mvv4FO#p1n&zt}N literal 1126 zcmeAS@N?(olHy`uVBq!ia0y~yV60|fU=-wFV_;yI707PRz`(#*9OUlAuNSs54@I14-?iy0XB4ude`@%$Aj3=GV_JzX3_D&pQ=+sJ#!fq})* zN8O)U#N*H3nQR$Hm%I>e_qaFn16RadvuAa870r5#6dFo5| T?ODXYz`)??>gTe~DWM4fN{(E! diff --git a/src/engine/color.ts b/src/engine/color.ts index 345c31bc..6eb60ba6 100644 --- a/src/engine/color.ts +++ b/src/engine/color.ts @@ -1,4 +1,4 @@ -import { Random } from './math'; +import { lerp, Random } from './math'; /** * Provides standard colors (e.g. {@apilink Color.Black}) @@ -228,6 +228,25 @@ export class Color { return hex.length === 1 ? '0' + hex : hex; } + /** + * Return linear representation of a color component + * @param c color component + * @param scale color gamma, 2.2 recommended as standard + */ + private static _COMPONENT_TO_LINEAR(c: number, scale: number = 2.2) { + return Math.pow(c, scale); + } + + /** + * Return color component from its linear representation + * @param c color component + * @param scale color gamma, 2.2 recommended as standard + * @private + */ + private static _COMPONENT_FROM_LINEAR(c: number, scale: number = 2.2) { + return Math.pow(c, 1.0 / scale); + } + /** * Return Hex representation of a color. */ @@ -277,15 +296,63 @@ export class Color { } /** - * Lerp between two colors + * Lerp between two colors different modes: + * - hsl (default) - a compromise between speed and naturalness of the gradient, suitable for most cases; + * - rgb - the fastest algorithm, but worse results for complex gradients; + * - lrgb - the most realistic result, but slower than the others. + */ + public static lerp(colorA: Color, colorB: Color, t: number, colorSpace: 'hsl' | 'rgb' | 'lrgb' = 'hsl'): Color { + switch (colorSpace) { + case 'hsl': + return Color.lerpHSL(colorA, colorB, t); + case 'rgb': + return Color.lerpRGB(colorA, colorB, t); + case 'lrgb': + return Color.lerpLRGB(colorA, colorB, t); + } + } + + /** + * Lerp between two colors using hsl as a compromise between speed and naturalness of the gradient */ - public static lerp(colorA: Color, colorB: Color, t: number): Color { + public static lerpHSL(colorA: Color, colorB: Color, t: number): Color { const color1: HSLColor = HSLColor.fromRGBA(colorA.r, colorA.g, colorA.b, colorA.a); const color2: HSLColor = HSLColor.fromRGBA(colorB.r, colorB.g, colorB.b, colorB.a); const newColor: HSLColor = HSLColor.lerp(color1, color2, t); return newColor.toRGBA(); } + /** + * Lerp between two colors using rgb for faster calculations + */ + public static lerpRGB(colorA: Color, colorB: Color, t: number): Color { + return new Color(lerp(colorA.r, colorB.r, t), lerp(colorA.g, colorB.g, t), lerp(colorA.b, colorB.b, t), lerp(colorA.a, colorB.a, t)); + } + + /** + * Lerp between two colors using lrgb for more realistic gradient + */ + public static lerpLRGB(colorA: Color, colorB: Color, t: number, gamma: number = 2.2): Color { + const rA = Color._COMPONENT_TO_LINEAR(colorA.r, gamma); + const gA = Color._COMPONENT_TO_LINEAR(colorA.g, gamma); + const bA = Color._COMPONENT_TO_LINEAR(colorA.b, gamma); + + const rB = Color._COMPONENT_TO_LINEAR(colorB.r, gamma); + const gB = Color._COMPONENT_TO_LINEAR(colorB.g, gamma); + const bB = Color._COMPONENT_TO_LINEAR(colorB.b, gamma); + + const rL = lerp(rA, rB, t); + const gL = lerp(gA, gB, t); + const bL = lerp(bA, bB, t); + + return new Color( + Color._COMPONENT_FROM_LINEAR(rL, gamma), + Color._COMPONENT_FROM_LINEAR(gL, gamma), + Color._COMPONENT_FROM_LINEAR(bL, gamma), + lerp(colorA.a, colorB.a, t) // keeping alpha linear + ); + } + public static random(rnd?: Random): Color { const rng: Random = rnd ?? new Random(); return new Color(rng.integer(0, 255), rng.integer(0, 255), rng.integer(0, 255)); diff --git a/src/spec/vitest/color-spec.ts b/src/spec/vitest/color-spec.ts index 26da2797..406a395c 100644 --- a/src/spec/vitest/color-spec.ts +++ b/src/spec/vitest/color-spec.ts @@ -149,10 +149,20 @@ describe('A color', () => { }); it('can be lerped', () => { - color = ex.Color.lerp(ex.Color.White, ex.Color.Black, 0.5); + color = ex.Color.lerp(ex.Color.White, ex.Color.Black, 0.5, 'hsl'); expect(color.r, 'r').toBe(127.5); expect(color.g, 'g').toBe(127.5); expect(color.b, 'b').toBe(127.5); + + color = ex.Color.lerp(ex.Color.White, ex.Color.Black, 0.5, 'rgb'); + expect(color.r, 'r').toBe(127.5); + expect(color.g, 'g').toBe(127.5); + expect(color.b, 'b').toBe(127.5); + + color = ex.Color.lerp(ex.Color.White, ex.Color.Black, 0.5, 'lrgb'); + expect(color.r, 'r').toBe(186.08371347438444); + expect(color.g, 'g').toBe(186.08371347438444); + expect(color.b, 'b').toBe(186.08371347438444); }); it('can be randomly generated', () => { -- 2.51.2