vireglass 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +10 -0
  3. package/README.md +216 -0
  4. package/android/build.gradle +15 -0
  5. package/android/src/main/AndroidManifest.xml +1 -0
  6. package/android/src/main/java/expo/modules/glasslens/GlassBackdropView.kt +335 -0
  7. package/android/src/main/java/expo/modules/glasslens/GlassLensModule.kt +49 -0
  8. package/android/src/main/java/expo/modules/glasslens/GlassLensView.kt +463 -0
  9. package/android/src/main/java/expo/modules/glasslens/GlassProbeView.kt +76 -0
  10. package/dist/adaptation-BSbbdExK.d.cts +78 -0
  11. package/dist/adaptation-BSbbdExK.d.ts +78 -0
  12. package/dist/adapters-BPB0D5nr.d.cts +357 -0
  13. package/dist/adapters-BgSc-smX.d.ts +357 -0
  14. package/dist/chunk-HF2AWZEZ.js +1741 -0
  15. package/dist/chunk-HF2AWZEZ.js.map +1 -0
  16. package/dist/chunk-JNRZNMJJ.js +20 -0
  17. package/dist/chunk-JNRZNMJJ.js.map +1 -0
  18. package/dist/index.cjs +2159 -0
  19. package/dist/index.cjs.map +1 -0
  20. package/dist/index.d.cts +294 -0
  21. package/dist/index.d.ts +294 -0
  22. package/dist/index.js +286 -0
  23. package/dist/index.js.map +1 -0
  24. package/dist/material-CkUHUBoy.d.cts +408 -0
  25. package/dist/material-CkUHUBoy.d.ts +408 -0
  26. package/dist/react.cjs +73 -0
  27. package/dist/react.cjs.map +1 -0
  28. package/dist/react.d.cts +25 -0
  29. package/dist/react.d.ts +25 -0
  30. package/dist/react.js +59 -0
  31. package/dist/react.js.map +1 -0
  32. package/dist/web/index.cjs +1990 -0
  33. package/dist/web/index.cjs.map +1 -0
  34. package/dist/web/index.d.cts +148 -0
  35. package/dist/web/index.d.ts +148 -0
  36. package/dist/web/index.js +657 -0
  37. package/dist/web/index.js.map +1 -0
  38. package/expo-module.config.json +6 -0
  39. package/package.json +127 -0
  40. package/src/accessibility.ts +51 -0
  41. package/src/adaptation.ts +85 -0
  42. package/src/adapters.ts +362 -0
  43. package/src/concentric.ts +25 -0
  44. package/src/geometry.ts +151 -0
  45. package/src/glass-scale.ts +37 -0
  46. package/src/group-model.ts +224 -0
  47. package/src/index.ts +17 -0
  48. package/src/lens-shader.ts +547 -0
  49. package/src/material.ts +553 -0
  50. package/src/native/environment.ts +70 -0
  51. package/src/native/glass-group.tsx +107 -0
  52. package/src/native/glass-ink.tsx +44 -0
  53. package/src/native/glass-surface.tsx +477 -0
  54. package/src/native/index.ts +18 -0
  55. package/src/native/lens.ts +106 -0
  56. package/src/native/provider.tsx +58 -0
  57. package/src/native/surface-registry.ts +51 -0
  58. package/src/native/system-accessibility.ts +90 -0
  59. package/src/optics.ts +177 -0
  60. package/src/react.ts +102 -0
  61. package/src/reference-scene.ts +222 -0
  62. package/src/scroll-edge.ts +30 -0
  63. package/src/sdf.ts +149 -0
  64. package/src/surface-shader.ts +287 -0
  65. package/src/targets/agsl.ts +8 -0
  66. package/src/targets/glsl.ts +140 -0
  67. package/src/touch-response.ts +195 -0
  68. package/src/web/gl.ts +119 -0
  69. package/src/web/index.ts +3 -0
  70. package/src/web/probe.ts +312 -0
  71. package/src/web/reference-draw.ts +142 -0
  72. package/src/web/renderer.ts +524 -0
@@ -0,0 +1,335 @@
1
+ package expo.modules.glasslens
2
+
3
+ import android.annotation.SuppressLint
4
+ import android.content.Context
5
+ import android.graphics.Canvas
6
+ import android.graphics.HardwareRenderer
7
+ import android.graphics.PixelFormat
8
+ import android.graphics.RenderNode
9
+ import android.graphics.drawable.ColorDrawable
10
+ import android.hardware.HardwareBuffer
11
+ import android.media.ImageReader
12
+ import android.os.Build
13
+ import android.os.Handler
14
+ import android.os.HandlerThread
15
+ import android.os.Looper
16
+ import android.util.Log
17
+ import android.view.View
18
+ import android.view.ViewTreeObserver
19
+ import expo.modules.kotlin.AppContext
20
+ import expo.modules.kotlin.views.ExpoView
21
+ import java.util.Collections
22
+ import java.util.WeakHashMap
23
+
24
+ /**
25
+ * Backdrop capture for the lens: screen content is written into a `RenderNode` WITHOUT blur.
26
+ *
27
+ * `BlurView` from expo-blur used to play this role, and it produced a dithered copy. Measured in
28
+ * the lab: a one-to-one sample from its capture gives noise of 6.4–8.0 against 0.000 on the same
29
+ * screen area right next to it. The cause is the path itself: on Android 13+ the library routes
30
+ * the capture through `RenderEffect.createBlurEffect`, and Skia dithers the blur's output to
31
+ * hide banding. At zero radius there's no blur, but the dithering stays. There's no blur here at
32
+ * all.
33
+ *
34
+ * Four things this implementation has already broken on — keep them in mind when editing:
35
+ *
36
+ * 1. The write must happen from `dispatchDraw`. `onPreDraw` doesn't yet have a valid drawing
37
+ * context, and `super.dispatchDraw` into a separately created canvas produces EMPTINESS.
38
+ * 2. The node can't be drawn onto the same canvas that just recorded it: the screen gets a stale
39
+ * frame, and the scene looks frozen even though the animation is running. Children are drawn
40
+ * directly; the node is handed only to the lens.
41
+ * 3. A re-record has to be requested every frame from `onPreDraw`: this view's own display list
42
+ * doesn't rebuild itself when only its children change — it just references their nodes.
43
+ * 4. `setPosition` is mandatory. A node is clipped by its own bounds, and a freshly created one
44
+ * has EMPTY bounds: `beginRecording(w, h)` only sets the recording's size, not its bounds.
45
+ * Without this, `drawRenderNode` draws nothing, `content.eval` in the lens returns zero
46
+ * alpha, and the glass turns transparent — while reading as "clean" on any noise metric,
47
+ * because there's nothing there for it to show (exactly the E-27 trap in material-lab.md).
48
+ */
49
+ @SuppressLint("ViewConstructor")
50
+ class GlassBackdropView(context: Context, appContext: AppContext) : ExpoView(context, appContext) {
51
+ private val node: RenderNode? =
52
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) RenderNode("vireglass-backdrop") else null
53
+
54
+ // Weak references: the lens leaves the screen before the backdrop does, and holding onto it
55
+ // here would mean holding its whole subtree.
56
+ private val lenses = Collections.newSetFromMap(WeakHashMap<GlassLensView, Boolean>())
57
+
58
+ private var capturing = false
59
+
60
+ private val onPreDraw = ViewTreeObserver.OnPreDrawListener {
61
+ if (lenses.isNotEmpty()) invalidate()
62
+ true
63
+ }
64
+
65
+ /** The frame's snapshot, or null before the first recording. */
66
+ val content: RenderNode?
67
+ get() = node?.takeIf { it.hasDisplayList() }
68
+
69
+ fun register(lens: GlassLensView) {
70
+ if (lenses.add(lens)) invalidate()
71
+ }
72
+
73
+ fun unregister(lens: GlassLensView) {
74
+ lenses.remove(lens)
75
+ }
76
+
77
+ override fun dispatchDraw(canvas: Canvas) {
78
+ val n = node
79
+ if (n == null || capturing || lenses.isEmpty() ||
80
+ !canvas.isHardwareAccelerated || width <= 0 || height <= 0
81
+ ) {
82
+ super.dispatchDraw(canvas)
83
+ return
84
+ }
85
+
86
+ capturing = true
87
+ try {
88
+ n.setPosition(0, 0, width, height)
89
+ val recording = n.beginRecording(width, height)
90
+ try {
91
+ // The screen's background comes from a style on the PARENT view, and only our children
92
+ // land in the node. Without the fill, the lens over an unpainted spot would sample
93
+ // transparency instead of a backdrop.
94
+ (parent as? View)?.background?.let { if (it is ColorDrawable) recording.drawColor(it.color) }
95
+ super.dispatchDraw(recording)
96
+ } finally {
97
+ n.endRecording()
98
+ }
99
+ } finally {
100
+ capturing = false
101
+ }
102
+
103
+ super.dispatchDraw(canvas)
104
+
105
+ // The lens rebuilds its own display list: it holds the offset to the backdrop, and that
106
+ // changes as the glass moves. The node's content is picked up by reference even without this.
107
+ for (lens in lenses) lens.invalidate()
108
+
109
+ scheduleProbe()
110
+ }
111
+
112
+ override fun onAttachedToWindow() {
113
+ super.onAttachedToWindow()
114
+ viewTreeObserver.addOnPreDrawListener(onPreDraw)
115
+ // The failure latch is only cleared here, on the view's new lifetime: resetting it inside
116
+ // `releaseProbe` would mean recreating the thread and the renderer every 180 ms on a stable
117
+ // failure.
118
+ probeFailed = false
119
+ }
120
+
121
+ override fun onDetachedFromWindow() {
122
+ viewTreeObserver.removeOnPreDrawListener(onPreDraw)
123
+ // The lens set is NOT cleared: the references are weak, and the backdrop can reconnect to a
124
+ // window, in which case the lenses would otherwise be left without updates forever.
125
+ node?.discardDisplayList()
126
+ releaseProbe()
127
+ super.onDetachedFromWindow()
128
+ }
129
+
130
+ // ─────────────────────────────────────────────────────────────────────────────
131
+ // LIGHTNESS PROBE
132
+ //
133
+ // The glass can adjust its own body to the backdrop, but there's a limit: when the backdrop is
134
+ // light AND the ink over it is light, no amount of body density will separate them by
135
+ // lightness anymore. Past that point the decision belongs not to the glass but to the APP — it
136
+ // recolors the ink. For that, the app needs to know the backdrop's lightness UNDER the glass,
137
+ // and only this view knows it.
138
+ //
139
+ // We don't read from the screen (the glass itself is already drawn there — that would create
140
+ // feedback) but from our own capture node: it holds exactly the backdrop and nothing else. The
141
+ // node is downscaled to a 16×32 grid and rendered into an `ImageReader` via a
142
+ // `HardwareRenderer` — the same mechanism Compose uses to snapshot its own layers. Once every
143
+ // PROBE_INTERVAL_MS, so it never enters the frame budget.
144
+ // ─────────────────────────────────────────────────────────────────────────────
145
+
146
+ private var probeNode: RenderNode? = null
147
+ private var probeReader: ImageReader? = null
148
+ private var probeRenderer: HardwareRenderer? = null
149
+ private var probeThread: HandlerThread? = null
150
+ private var probeHandler: Handler? = null
151
+ private var probeFailed = false
152
+ private var lastProbeAt = 0L
153
+ private var probePending = false
154
+
155
+ /** Lightness grid of the latest snapshot, PROBE_W×PROBE_H, values 0..1. */
156
+ @Volatile
157
+ var lumaGrid: FloatArray? = null
158
+ private set
159
+
160
+ /** Average color of the same snapshot, per cell: the lens tints the rim with the content's
161
+ * color. */
162
+ @Volatile
163
+ var colorGrid: FloatArray? = null
164
+ private set
165
+
166
+ private val mainHandler = Handler(Looper.getMainLooper())
167
+
168
+ private fun scheduleProbe() {
169
+ if (probeFailed || Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return
170
+ if (probePending) return
171
+ val now = android.os.SystemClock.uptimeMillis()
172
+ if (now - lastProbeAt < PROBE_INTERVAL_MS) return
173
+ lastProbeAt = now
174
+ probePending = true
175
+ // Not from inside the draw pass: `syncAndDraw` synchronizes the node tree, and we're right
176
+ // in the middle of traversing it. `post` schedules the render right after the frame.
177
+ post { renderProbe() }
178
+ }
179
+
180
+ private fun renderProbe() {
181
+ probePending = false
182
+ val n = content ?: return
183
+ if (width <= 0 || height <= 0) return
184
+ try {
185
+ ensureProbe() ?: return
186
+ val pn = probeNode ?: return
187
+ pn.setPosition(0, 0, SHOT_W, SHOT_H)
188
+ val c = pn.beginRecording(SHOT_W, SHOT_H)
189
+ try {
190
+ c.scale(SHOT_W.toFloat() / width, SHOT_H.toFloat() / height)
191
+ c.drawRenderNode(n)
192
+ } finally {
193
+ pn.endRecording()
194
+ }
195
+ probeRenderer?.createRenderRequest()?.setWaitForPresent(false)?.syncAndDraw()
196
+ } catch (e: Throwable) {
197
+ // The probe isn't a required part: without it, the glass only loses theme auto-inversion.
198
+ Log.w("GlassLens", "lightness probe disabled", e)
199
+ probeFailed = true
200
+ releaseProbe()
201
+ }
202
+ }
203
+
204
+ private fun ensureProbe(): HardwareRenderer? {
205
+ probeRenderer?.let { return it }
206
+ val thread = HandlerThread("vireglass-probe").apply { start() }
207
+ probeThread = thread
208
+ val handler = Handler(thread.looper)
209
+ probeHandler = handler
210
+ val reader = ImageReader.newInstance(
211
+ SHOT_W,
212
+ SHOT_H,
213
+ PixelFormat.RGBA_8888,
214
+ 2,
215
+ HardwareBuffer.USAGE_GPU_COLOR_OUTPUT or HardwareBuffer.USAGE_CPU_READ_OFTEN,
216
+ )
217
+ reader.setOnImageAvailableListener({ r -> readProbe(r) }, handler)
218
+ probeReader = reader
219
+ val pn = RenderNode("vireglass-probe")
220
+ probeNode = pn
221
+ val renderer = HardwareRenderer().apply {
222
+ setSurface(reader.surface)
223
+ setContentRoot(pn)
224
+ }
225
+ probeRenderer = renderer
226
+ return renderer
227
+ }
228
+
229
+ private fun readProbe(reader: ImageReader) {
230
+ val image = try {
231
+ reader.acquireLatestImage()
232
+ } catch (e: Throwable) {
233
+ Log.w("GlassLens", "probe frame wasn't delivered", e)
234
+ null
235
+ } ?: return
236
+ try {
237
+ val plane = image.planes[0]
238
+ val buf = plane.buffer
239
+ val rowStride = plane.rowStride
240
+ val pixelStride = plane.pixelStride
241
+ val luma = FloatArray(PROBE_W * PROBE_H)
242
+ val color = FloatArray(PROBE_W * PROBE_H * 3)
243
+ val cells = (SHOT_BLOCK * SHOT_BLOCK).toFloat()
244
+ for (y in 0 until PROBE_H) {
245
+ for (x in 0 until PROBE_W) {
246
+ var r = 0f
247
+ var g = 0f
248
+ var b = 0f
249
+ for (dy in 0 until SHOT_BLOCK) {
250
+ for (dx in 0 until SHOT_BLOCK) {
251
+ val o = (y * SHOT_BLOCK + dy) * rowStride + (x * SHOT_BLOCK + dx) * pixelStride
252
+ r += (buf.get(o).toInt() and 0xFF) / 255f
253
+ g += (buf.get(o + 1).toInt() and 0xFF) / 255f
254
+ b += (buf.get(o + 2).toInt() and 0xFF) / 255f
255
+ }
256
+ }
257
+ r /= cells
258
+ g /= cells
259
+ b /= cells
260
+ val i = y * PROBE_W + x
261
+ luma[i] = 0.2126f * r + 0.7152f * g + 0.0722f * b
262
+ color[i * 3] = r
263
+ color[i * 3 + 1] = g
264
+ color[i * 3 + 2] = b
265
+ }
266
+ }
267
+ lumaGrid = luma
268
+ colorGrid = color
269
+ } catch (e: Throwable) {
270
+ Log.w("GlassLens", "probe snapshot failed to read", e)
271
+ } finally {
272
+ // The frame closes on the probe thread and can arrive late: `releaseProbe` from the main
273
+ // thread may have already closed the reader, in which case `close()` throws
274
+ // `IllegalStateException`.
275
+ try {
276
+ image.close()
277
+ } catch (e: Throwable) {
278
+ Log.w("GlassLens", "probe frame failed to close", e)
279
+ }
280
+ }
281
+ mainHandler.post { for (lens in lenses) lens.onBackdropProbed() }
282
+ }
283
+
284
+ private fun releaseProbe() {
285
+ probeRenderer?.let {
286
+ it.stop()
287
+ // The surface is detached BEFORE destroy: otherwise the runtime logs "A resource failed to
288
+ // call Surface.release" on every recreated probe.
289
+ it.setSurface(null)
290
+ it.destroy()
291
+ }
292
+ probeRenderer = null
293
+ probeReader?.close()
294
+ probeReader = null
295
+ probeNode?.discardDisplayList()
296
+ probeNode = null
297
+ probeThread?.quitSafely()
298
+ probeThread = null
299
+ probeHandler = null
300
+ lumaGrid = null
301
+ colorGrid = null
302
+ }
303
+
304
+ companion object {
305
+ /**
306
+ * The probe's grid. A cell must be SMALLER than the finest texture that needs telling apart:
307
+ * at 16×32 a cell came out to 67 pixels, and an 8 dp checkerboard averaged out to flat gray —
308
+ * the probe honestly reported "the backdrop is uniform," while ink over the glass drowned in
309
+ * the cells. At 48×96 a cell is about 22 pixels, i.e. the size of the product's finest
310
+ * texture. The breakdown runs on a background thread once every PROBE_INTERVAL_MS, so it
311
+ * never enters the frame budget.
312
+ */
313
+ const val PROBE_W = 48
314
+ const val PROBE_H = 96
315
+
316
+ /**
317
+ * The snapshot is taken LARGER than the grid, and a cell is then averaged over a
318
+ * SHOT_BLOCK×SHOT_BLOCK block.
319
+ *
320
+ * Downscaling the screen straight to the grid doesn't work: at a twenty-five-fold reduction
321
+ * the rasterizer doesn't average, it filters — at the 0.50 → 0.69 step a cell came out to
322
+ * 0.486, i.e. below BOTH levels. On flat fields this goes unnoticed, but on a gradient the
323
+ * probe used to underestimate the lightness slope by almost a third, and the element's body
324
+ * on Android tracked the backdrop more closely than on the web (issue #113). The web doesn't
325
+ * make this mistake: there, a shader computes the cell by averaging a 5×5 block.
326
+ */
327
+ const val SHOT_BLOCK = 5
328
+ const val SHOT_W = PROBE_W * SHOT_BLOCK
329
+ const val SHOT_H = PROBE_H * SHOT_BLOCK
330
+
331
+ /** Snapshot rate. The adaptation travels over roughly half a second regardless, so there's
332
+ * no point going faster, and this shouldn't touch the frame budget either way. */
333
+ const val PROBE_INTERVAL_MS = 180L
334
+ }
335
+ }
@@ -0,0 +1,49 @@
1
+ package expo.modules.glasslens
2
+
3
+ import android.os.Build
4
+ import expo.modules.kotlin.modules.Module
5
+ import expo.modules.kotlin.modules.ModuleDefinition
6
+
7
+ class GlassLensModule : Module() {
8
+ override fun definition() = ModuleDefinition {
9
+ Name("GlassLens")
10
+
11
+ // `createRuntimeShaderEffect` arrived in Android 13. Below it, the view works as an ordinary
12
+ // container, and JS keeps the old affine magnification as a fallback.
13
+ Constants("isSupported" to (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU))
14
+
15
+ View(GlassLensView::class) {
16
+ Name("GlassLensView")
17
+
18
+ // Lightness of the backdrop under the glass. An event, not a prop: the app has to learn
19
+ // the limit past which the glass can no longer separate itself from the ink, and recolor
20
+ // the ink itself.
21
+ Events("onBackdropSample")
22
+
23
+ Prop("backdropId") { view: GlassLensView, value: Int? -> view.backdropId = value }
24
+ Prop("shaderSource") { view: GlassLensView, value: String -> view.shaderSource = value }
25
+ Prop("glassWidth") { view: GlassLensView, value: Float -> view.glassWidth = value }
26
+ Prop("glassHeight") { view: GlassLensView, value: Float -> view.glassHeight = value }
27
+ // The whole material as one channel: name ↔ size ↔ value arrive together, so drifting out
28
+ // of sync with the shader silently is no longer possible (see `applyChannel`).
29
+ Prop("uniformNames") { view: GlassLensView, value: List<String> -> view.uniformNames = value }
30
+ Prop("uniformValues") { view: GlassLensView, value: List<Double> -> view.uniformValues = value }
31
+ Prop("uniformSizes") { view: GlassLensView, value: List<Int> -> view.uniformSizes = value }
32
+
33
+ OnViewDidUpdateProps { view: GlassLensView -> view.applyEffect() }
34
+ }
35
+
36
+ // Backdrop capture: writes its children into a RenderNode, the lens takes it as content.
37
+ View(GlassBackdropView::class) {
38
+ Name("GlassBackdropView")
39
+ }
40
+
41
+ // Diagnostic probe (docs/adr-001-rendering.md): shows which pixels actually reach
42
+ // `createRuntimeShaderEffect`. Not used in production UI.
43
+ View(GlassProbeView::class) {
44
+ Name("GlassProbeView")
45
+ Prop("mode") { view: GlassProbeView, value: Float -> view.mode = value }
46
+ OnViewDidUpdateProps { view: GlassProbeView -> view.applyEffect() }
47
+ }
48
+ }
49
+ }