bg-pulse-render.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436
  1. import { OptimizedBuffer, RGBA, TextAttributes } from "@opentui/core"
  2. import { go } from "../logo"
  3. const PERIOD = 4600
  4. const RINGS = 3
  5. const WIDTH = 3.8
  6. const TAIL = 9.5
  7. const AMP = 0.55
  8. const TAIL_AMP = 0.16
  9. const BREATH_AMP = 0.05
  10. const BREATH_SPEED = 0.0008
  11. // Offset so the bg ring emits from the estimated GO center when the logo shimmer peaks.
  12. const PHASE_OFFSET = 0.29
  13. const LOGO_GAP = 1
  14. const LOGO_TOP_BIAS = -1
  15. const LOGO_LEFT_WIDTH = go.left[0]?.length ?? 0
  16. const LOGO_LINES = go.left.map((line, index) => line + " ".repeat(LOGO_GAP) + go.right[index])
  17. const LOGO_WIDTH = LOGO_LINES[0]?.length ?? 0
  18. const LOGO_HEIGHT = LOGO_LINES.length
  19. const SPACE = " ".codePointAt(0)!
  20. const TOP_HALF = "▀".codePointAt(0)!
  21. const FULL_BLOCK = "█".codePointAt(0)!
  22. const RING_SCALE = 1 / RINGS
  23. const TAIL_SCALE = 1 / TAIL
  24. const LOGO_REACH = Math.hypot(LOGO_WIDTH, LOGO_HEIGHT * 2) + 3
  25. const enum LogoCellKind {
  26. Background,
  27. Top,
  28. ShadowTop,
  29. Solid,
  30. Char,
  31. }
  32. type LogoTemplateCell = {
  33. x: number
  34. y: number
  35. kind: LogoCellKind
  36. charCode: number
  37. attributes: number
  38. topDist: number
  39. bottomDist: number
  40. }
  41. const LOGO_TEMPLATE: LogoTemplateCell[] = LOGO_LINES.flatMap((line, y) =>
  42. Array.from(line)
  43. .map((char, x) => {
  44. if (char === " ") return
  45. const kind =
  46. char === "_"
  47. ? LogoCellKind.Background
  48. : char === "^"
  49. ? LogoCellKind.Top
  50. : char === "~"
  51. ? LogoCellKind.ShadowTop
  52. : char === "█"
  53. ? LogoCellKind.Solid
  54. : LogoCellKind.Char
  55. return {
  56. x,
  57. y,
  58. kind,
  59. charCode: char.codePointAt(0) ?? SPACE,
  60. attributes: x > LOGO_LEFT_WIDTH ? TextAttributes.BOLD : 0,
  61. topDist: Math.hypot(x + 0.5 - LOGO_WIDTH / 2, y * 2 - LOGO_HEIGHT),
  62. bottomDist: Math.hypot(x + 0.5 - LOGO_WIDTH / 2, y * 2 + 1 - LOGO_HEIGHT),
  63. }
  64. })
  65. .filter((cell): cell is LogoTemplateCell => !!cell),
  66. )
  67. export type Rgb = [number, number, number]
  68. export type GoUpsellArtRenderOptions = {
  69. deltaTime?: number
  70. rgb?: boolean
  71. cache?: boolean
  72. }
  73. const CACHE_FRAME_COUNT = Math.round(PERIOD / (1000 / 30))
  74. const CACHE_FRAMES_PER_RENDER = 1
  75. export function toRgb(color: RGBA): Rgb {
  76. const [r, g, b] = color.toInts()
  77. return [r, g, b]
  78. }
  79. function clamp(n: number) {
  80. return Math.max(0, Math.min(1, n))
  81. }
  82. function writeRgb(buffer: Uint16Array, offset: number, r: number, g: number, b: number, a = 255) {
  83. buffer[offset] = r
  84. buffer[offset + 1] = g
  85. buffer[offset + 2] = b
  86. buffer[offset + 3] = a
  87. }
  88. function mixChannel(base: number, overlay: number, alpha: number) {
  89. return Math.round(base + (overlay - base) * clamp(alpha))
  90. }
  91. function writeLogoTint(
  92. buffer: Uint16Array,
  93. offset: number,
  94. base: Rgb,
  95. primary: Rgb,
  96. primaryMix: number,
  97. peakMix: number,
  98. ) {
  99. const p = clamp(primaryMix)
  100. const q = clamp(peakMix)
  101. const r = mixChannel(mixChannel(base[0], primary[0], p), 255, q)
  102. const g = mixChannel(mixChannel(base[1], primary[1], p), 255, q)
  103. const b = mixChannel(mixChannel(base[2], primary[2], p), 255, q)
  104. writeRgb(buffer, offset, r, g, b)
  105. }
  106. function sameRgb(a: Rgb, b: Rgb) {
  107. return a[0] === b[0] && a[1] === b[1] && a[2] === b[2]
  108. }
  109. export class GoUpsellArtPainter {
  110. private panelRgb: Rgb = [0, 0, 0]
  111. private primaryRgb: Rgb = [255, 255, 255]
  112. private logoBaseRgb: Rgb = [180, 180, 180]
  113. private elapsed = 0
  114. private distances = new Float32Array(0)
  115. private edgeFalloff = new Float32Array(0)
  116. private geometryWidth = 0
  117. private geometryHeight = 0
  118. private reach = 1
  119. private logoX = 0
  120. private logoY = 0
  121. private logoIndexes = new Int32Array(0)
  122. private logoRgb: boolean | undefined
  123. private pulsePeak = 0
  124. private pulsePrimary = 0
  125. private cacheDirty = true
  126. private frameCache: Array<{ fg: Uint16Array; bg: Uint16Array }> = []
  127. private cacheBuildIndex = 0
  128. setBackgroundPanel(value: RGBA | Rgb | undefined) {
  129. if (!value) return false
  130. const next = value instanceof RGBA ? toRgb(value) : value
  131. if (sameRgb(this.panelRgb, next)) return false
  132. this.panelRgb = next
  133. this.invalidateCache()
  134. return true
  135. }
  136. setLogoBase(value: RGBA | Rgb | undefined) {
  137. if (!value) return false
  138. const next = value instanceof RGBA ? toRgb(value) : value
  139. if (sameRgb(this.logoBaseRgb, next)) return false
  140. this.logoBaseRgb = next
  141. this.invalidateCache()
  142. return true
  143. }
  144. setPrimary(value: RGBA | Rgb | undefined) {
  145. if (!value) return false
  146. const next = value instanceof RGBA ? toRgb(value) : value
  147. if (sameRgb(this.primaryRgb, next)) return false
  148. this.primaryRgb = next
  149. this.invalidateCache()
  150. return true
  151. }
  152. render(frameBuffer: OptimizedBuffer, options: GoUpsellArtRenderOptions = {}) {
  153. const rgb = options.rgb === true
  154. this.elapsed = (this.elapsed + (options.deltaTime ?? 0)) % PERIOD
  155. this.rebuildGeometry(frameBuffer, rgb)
  156. if (options.cache !== false) {
  157. this.drawCached(frameBuffer, rgb)
  158. return
  159. }
  160. this.drawBackground(frameBuffer, this.elapsed)
  161. this.drawLogo(frameBuffer, this.elapsed, rgb)
  162. }
  163. private invalidateCache() {
  164. this.cacheDirty = true
  165. this.cacheBuildIndex = 0
  166. this.frameCache = []
  167. }
  168. private rebuildGeometry(frameBuffer: OptimizedBuffer, rgb: boolean) {
  169. const width = frameBuffer.width
  170. const height = frameBuffer.height
  171. const geometryChanged = width !== this.geometryWidth || height !== this.geometryHeight
  172. const logoTemplateChanged = this.logoRgb !== rgb
  173. if (!geometryChanged && !logoTemplateChanged) return
  174. if (geometryChanged) {
  175. this.geometryWidth = width
  176. this.geometryHeight = height
  177. this.logoX = Math.max(0, Math.floor((width - LOGO_WIDTH) / 2))
  178. this.logoY = Math.max(
  179. 0,
  180. Math.min(Math.max(0, height - LOGO_HEIGHT), Math.round((height - LOGO_HEIGHT) / 2) + LOGO_TOP_BIAS),
  181. )
  182. const centerX = this.logoX + LOGO_WIDTH / 2
  183. const centerY = this.logoY + LOGO_HEIGHT / 2
  184. this.reach = Math.hypot(Math.max(centerX, width - centerX), Math.max(centerY, height - centerY) * 2) + TAIL
  185. this.distances = new Float32Array(width * height)
  186. this.edgeFalloff = new Float32Array(width * height)
  187. for (let y = 0; y < height; y++) {
  188. for (let x = 0; x < width; x++) {
  189. const index = y * width + x
  190. const dist = Math.hypot(x + 0.5 - centerX, (y + 0.5 - centerY) * 2)
  191. this.distances[index] = dist
  192. this.edgeFalloff[index] = Math.max(0, 1 - (dist / (this.reach * 0.85)) ** 2)
  193. }
  194. }
  195. }
  196. this.logoRgb = rgb
  197. this.invalidateCache()
  198. this.rebuildCellTemplate(frameBuffer, rgb)
  199. }
  200. private drawCached(frameBuffer: OptimizedBuffer, rgb: boolean) {
  201. if (this.cacheDirty) this.startFrameCache(frameBuffer, rgb)
  202. if (this.cacheBuildIndex < CACHE_FRAME_COUNT) {
  203. this.buildFrameCache(frameBuffer, rgb)
  204. this.drawBackground(frameBuffer, this.elapsed)
  205. this.drawLogo(frameBuffer, this.elapsed, rgb)
  206. return
  207. }
  208. const frame = this.frameCache[Math.floor((this.elapsed / PERIOD) * CACHE_FRAME_COUNT) % CACHE_FRAME_COUNT]
  209. if (frame) {
  210. frameBuffer.buffers.fg.set(frame.fg)
  211. frameBuffer.buffers.bg.set(frame.bg)
  212. }
  213. }
  214. private startFrameCache(frameBuffer: OptimizedBuffer, rgb: boolean) {
  215. this.frameCache = []
  216. this.cacheBuildIndex = 0
  217. this.rebuildCellTemplate(frameBuffer, rgb)
  218. this.cacheDirty = false
  219. }
  220. private buildFrameCache(frameBuffer: OptimizedBuffer, rgb: boolean) {
  221. const end = Math.min(CACHE_FRAME_COUNT, this.cacheBuildIndex + CACHE_FRAMES_PER_RENDER)
  222. for (; this.cacheBuildIndex < end; this.cacheBuildIndex++) {
  223. const t = (this.cacheBuildIndex / CACHE_FRAME_COUNT) * PERIOD
  224. this.drawBackground(frameBuffer, t)
  225. this.drawLogo(frameBuffer, t, rgb)
  226. this.frameCache.push({
  227. fg: new Uint16Array(frameBuffer.buffers.fg),
  228. bg: new Uint16Array(frameBuffer.buffers.bg),
  229. })
  230. }
  231. }
  232. private rebuildCellTemplate(frameBuffer: OptimizedBuffer, rgb: boolean) {
  233. const buffers = frameBuffer.buffers
  234. buffers.char.fill(SPACE)
  235. buffers.attributes.fill(0)
  236. if (this.geometryWidth < LOGO_WIDTH || this.geometryHeight < LOGO_HEIGHT) {
  237. this.logoIndexes = new Int32Array(0)
  238. return
  239. }
  240. this.logoIndexes = new Int32Array(LOGO_TEMPLATE.length)
  241. for (let i = 0; i < LOGO_TEMPLATE.length; i++) {
  242. const cell = LOGO_TEMPLATE[i]!
  243. const index = (this.logoY + cell.y) * this.geometryWidth + this.logoX + cell.x
  244. this.logoIndexes[i] = index
  245. buffers.attributes[index] = cell.attributes
  246. buffers.char[index] =
  247. cell.kind === LogoCellKind.Background
  248. ? SPACE
  249. : cell.kind === LogoCellKind.Top || cell.kind === LogoCellKind.ShadowTop
  250. ? TOP_HALF
  251. : cell.kind === LogoCellKind.Solid
  252. ? rgb
  253. ? TOP_HALF
  254. : FULL_BLOCK
  255. : cell.charCode
  256. }
  257. }
  258. private drawBackground(frameBuffer: OptimizedBuffer, t: number) {
  259. const buffers = frameBuffer.buffers
  260. const fg = buffers.fg
  261. const bg = buffers.bg
  262. const distances = this.distances
  263. const edgeFalloff = this.edgeFalloff
  264. const baseR = this.panelRgb[0]
  265. const baseG = this.panelRgb[1]
  266. const baseB = this.panelRgb[2]
  267. const deltaR = this.primaryRgb[0] - baseR
  268. const deltaG = this.primaryRgb[1] - baseG
  269. const deltaB = this.primaryRgb[2] - baseB
  270. const breath = (0.5 + 0.5 * Math.sin(t * BREATH_SPEED)) * BREATH_AMP
  271. const phase0 = (t / PERIOD - PHASE_OFFSET + 1) % 1
  272. const phase1 = (t / PERIOD + 1 / RINGS - PHASE_OFFSET + 1) % 1
  273. const phase2 = (t / PERIOD + 2 / RINGS - PHASE_OFFSET + 1) % 1
  274. const envelope0 = Math.sin(phase0 * Math.PI)
  275. const envelope1 = Math.sin(phase1 * Math.PI)
  276. const envelope2 = Math.sin(phase2 * Math.PI)
  277. const eased0 = envelope0 * envelope0 * (3 - 2 * envelope0)
  278. const eased1 = envelope1 * envelope1 * (3 - 2 * envelope1)
  279. const eased2 = envelope2 * envelope2 * (3 - 2 * envelope2)
  280. const head0 = phase0 * this.reach
  281. const head1 = phase1 * this.reach
  282. const head2 = phase2 * this.reach
  283. for (let index = 0; index < distances.length; index++) {
  284. const dist = distances[index]
  285. const delta0 = dist - head0
  286. const abs0 = delta0 < 0 ? -delta0 : delta0
  287. const crest0 = abs0 < WIDTH ? 0.5 + 0.5 * Math.cos((delta0 / WIDTH) * Math.PI) : 0
  288. const tail0 = delta0 < 0 && delta0 > -TAIL ? (1 + delta0 * TAIL_SCALE) ** 2.3 : 0
  289. const delta1 = dist - head1
  290. const abs1 = delta1 < 0 ? -delta1 : delta1
  291. const crest1 = abs1 < WIDTH ? 0.5 + 0.5 * Math.cos((delta1 / WIDTH) * Math.PI) : 0
  292. const tail1 = delta1 < 0 && delta1 > -TAIL ? (1 + delta1 * TAIL_SCALE) ** 2.3 : 0
  293. const delta2 = dist - head2
  294. const abs2 = delta2 < 0 ? -delta2 : delta2
  295. const crest2 = abs2 < WIDTH ? 0.5 + 0.5 * Math.cos((delta2 / WIDTH) * Math.PI) : 0
  296. const tail2 = delta2 < 0 && delta2 > -TAIL ? (1 + delta2 * TAIL_SCALE) ** 2.3 : 0
  297. const level =
  298. (crest0 * AMP + tail0 * TAIL_AMP) * eased0 +
  299. (crest1 * AMP + tail1 * TAIL_AMP) * eased1 +
  300. (crest2 * AMP + tail2 * TAIL_AMP) * eased2
  301. const rawStrength = (level * RING_SCALE + breath) * edgeFalloff[index]
  302. const strength = (rawStrength > 1 ? 1 : rawStrength) * 0.7
  303. const offset = index * 4
  304. const r = Math.round(baseR + deltaR * strength)
  305. const g = Math.round(baseG + deltaG * strength)
  306. const b = Math.round(baseB + deltaB * strength)
  307. bg[offset] = fg[offset] = r
  308. bg[offset + 1] = fg[offset + 1] = g
  309. bg[offset + 2] = fg[offset + 2] = b
  310. bg[offset + 3] = fg[offset + 3] = 255
  311. }
  312. }
  313. private setLogoPulse(dist: number, head0: number, eased0: number, head1: number, eased1: number) {
  314. let peak = 0.04
  315. let primary = 0
  316. const delta0 = dist - head0
  317. const core0 = Math.exp(-(Math.abs(delta0 / 1.2) ** 1.8))
  318. const soft0 = Math.exp(-(Math.abs(delta0 / 7) ** 1.6))
  319. const tail0 = delta0 < 0 && delta0 > -7 ? (1 + delta0 / 7) ** 2.6 : 0
  320. peak += core0 * 0.65 * eased0
  321. primary += (soft0 * 0.16 + tail0 * 0.22) * eased0
  322. const delta1 = dist - head1
  323. const core1 = Math.exp(-(Math.abs(delta1 / 1.2) ** 1.8))
  324. const soft1 = Math.exp(-(Math.abs(delta1 / 7) ** 1.6))
  325. const tail1 = delta1 < 0 && delta1 > -7 ? (1 + delta1 / 7) ** 2.6 : 0
  326. peak += core1 * 0.65 * eased1
  327. primary += (soft1 * 0.16 + tail1 * 0.22) * eased1
  328. this.pulsePeak = peak > 1 ? 1 : peak
  329. this.pulsePrimary = primary > 1 ? 1 : primary
  330. }
  331. private drawLogo(frameBuffer: OptimizedBuffer, t: number, rgb: boolean) {
  332. if (this.logoIndexes.length === 0) return
  333. const buffers = frameBuffer.buffers
  334. const fg = buffers.fg
  335. const bg = buffers.bg
  336. const shadow: Rgb = [
  337. mixChannel(this.panelRgb[0], this.logoBaseRgb[0], 0.25),
  338. mixChannel(this.panelRgb[1], this.logoBaseRgb[1], 0.25),
  339. mixChannel(this.panelRgb[2], this.logoBaseRgb[2], 0.25),
  340. ]
  341. const phase0 = (t / PERIOD) % 1
  342. const phase1 = (t / PERIOD + 0.5) % 1
  343. const envelope0 = Math.sin(phase0 * Math.PI)
  344. const envelope1 = Math.sin(phase1 * Math.PI)
  345. const eased0 = envelope0 * envelope0 * (3 - 2 * envelope0)
  346. const eased1 = envelope1 * envelope1 * (3 - 2 * envelope1)
  347. const head0 = phase0 * LOGO_REACH
  348. const head1 = phase1 * LOGO_REACH
  349. for (let i = 0; i < LOGO_TEMPLATE.length; i++) {
  350. const cell = LOGO_TEMPLATE[i]!
  351. const index = this.logoIndexes[i]!
  352. const offset = index * 4
  353. this.setLogoPulse(cell.topDist, head0, eased0, head1, eased1)
  354. const topPeak = this.pulsePeak
  355. const topPrimary = this.pulsePrimary
  356. this.setLogoPulse(cell.bottomDist, head0, eased0, head1, eased1)
  357. const bottomPeak = this.pulsePeak
  358. const bottomPrimary = this.pulsePrimary
  359. if (cell.kind === LogoCellKind.Background) {
  360. writeLogoTint(bg, offset, shadow, this.primaryRgb, 0, Math.max(topPeak, bottomPeak) * 0.18)
  361. continue
  362. }
  363. if (cell.kind === LogoCellKind.Top) {
  364. writeLogoTint(fg, offset, this.logoBaseRgb, this.primaryRgb, topPrimary, topPeak)
  365. writeLogoTint(bg, offset, shadow, this.primaryRgb, 0, bottomPeak * 0.18)
  366. continue
  367. }
  368. if (cell.kind === LogoCellKind.ShadowTop) {
  369. writeLogoTint(fg, offset, shadow, this.primaryRgb, 0, topPeak * 0.18)
  370. continue
  371. }
  372. if (cell.kind === LogoCellKind.Solid && rgb) {
  373. writeLogoTint(fg, offset, this.logoBaseRgb, this.primaryRgb, topPrimary, topPeak)
  374. writeLogoTint(bg, offset, this.logoBaseRgb, this.primaryRgb, bottomPrimary, bottomPeak)
  375. continue
  376. }
  377. writeLogoTint(
  378. fg,
  379. offset,
  380. this.logoBaseRgb,
  381. this.primaryRgb,
  382. (topPrimary + bottomPrimary) / 2,
  383. (topPeak + bottomPeak) / 2,
  384. )
  385. }
  386. }
  387. }