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.
- package/LICENSE +202 -0
- package/NOTICE +10 -0
- package/README.md +216 -0
- package/android/build.gradle +15 -0
- package/android/src/main/AndroidManifest.xml +1 -0
- package/android/src/main/java/expo/modules/glasslens/GlassBackdropView.kt +335 -0
- package/android/src/main/java/expo/modules/glasslens/GlassLensModule.kt +49 -0
- package/android/src/main/java/expo/modules/glasslens/GlassLensView.kt +463 -0
- package/android/src/main/java/expo/modules/glasslens/GlassProbeView.kt +76 -0
- package/dist/adaptation-BSbbdExK.d.cts +78 -0
- package/dist/adaptation-BSbbdExK.d.ts +78 -0
- package/dist/adapters-BPB0D5nr.d.cts +357 -0
- package/dist/adapters-BgSc-smX.d.ts +357 -0
- package/dist/chunk-HF2AWZEZ.js +1741 -0
- package/dist/chunk-HF2AWZEZ.js.map +1 -0
- package/dist/chunk-JNRZNMJJ.js +20 -0
- package/dist/chunk-JNRZNMJJ.js.map +1 -0
- package/dist/index.cjs +2159 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +294 -0
- package/dist/index.d.ts +294 -0
- package/dist/index.js +286 -0
- package/dist/index.js.map +1 -0
- package/dist/material-CkUHUBoy.d.cts +408 -0
- package/dist/material-CkUHUBoy.d.ts +408 -0
- package/dist/react.cjs +73 -0
- package/dist/react.cjs.map +1 -0
- package/dist/react.d.cts +25 -0
- package/dist/react.d.ts +25 -0
- package/dist/react.js +59 -0
- package/dist/react.js.map +1 -0
- package/dist/web/index.cjs +1990 -0
- package/dist/web/index.cjs.map +1 -0
- package/dist/web/index.d.cts +148 -0
- package/dist/web/index.d.ts +148 -0
- package/dist/web/index.js +657 -0
- package/dist/web/index.js.map +1 -0
- package/expo-module.config.json +6 -0
- package/package.json +127 -0
- package/src/accessibility.ts +51 -0
- package/src/adaptation.ts +85 -0
- package/src/adapters.ts +362 -0
- package/src/concentric.ts +25 -0
- package/src/geometry.ts +151 -0
- package/src/glass-scale.ts +37 -0
- package/src/group-model.ts +224 -0
- package/src/index.ts +17 -0
- package/src/lens-shader.ts +547 -0
- package/src/material.ts +553 -0
- package/src/native/environment.ts +70 -0
- package/src/native/glass-group.tsx +107 -0
- package/src/native/glass-ink.tsx +44 -0
- package/src/native/glass-surface.tsx +477 -0
- package/src/native/index.ts +18 -0
- package/src/native/lens.ts +106 -0
- package/src/native/provider.tsx +58 -0
- package/src/native/surface-registry.ts +51 -0
- package/src/native/system-accessibility.ts +90 -0
- package/src/optics.ts +177 -0
- package/src/react.ts +102 -0
- package/src/reference-scene.ts +222 -0
- package/src/scroll-edge.ts +30 -0
- package/src/sdf.ts +149 -0
- package/src/surface-shader.ts +287 -0
- package/src/targets/agsl.ts +8 -0
- package/src/targets/glsl.ts +140 -0
- package/src/touch-response.ts +195 -0
- package/src/web/gl.ts +119 -0
- package/src/web/index.ts +3 -0
- package/src/web/probe.ts +312 -0
- package/src/web/reference-draw.ts +142 -0
- package/src/web/renderer.ts +524 -0
|
@@ -0,0 +1,463 @@
|
|
|
1
|
+
package expo.modules.glasslens
|
|
2
|
+
|
|
3
|
+
import android.annotation.SuppressLint
|
|
4
|
+
import android.content.Context
|
|
5
|
+
import android.graphics.RenderEffect
|
|
6
|
+
import android.graphics.RuntimeShader
|
|
7
|
+
import android.os.Build
|
|
8
|
+
import android.graphics.Canvas
|
|
9
|
+
import android.util.Log
|
|
10
|
+
import android.view.View
|
|
11
|
+
import android.view.ViewGroup
|
|
12
|
+
import expo.modules.kotlin.AppContext
|
|
13
|
+
import expo.modules.kotlin.viewevent.EventDispatcher
|
|
14
|
+
import expo.modules.kotlin.views.ExpoView
|
|
15
|
+
import kotlin.math.abs
|
|
16
|
+
import kotlin.math.max
|
|
17
|
+
import kotlin.math.min
|
|
18
|
+
|
|
19
|
+
// Refraction of a live backdrop can't be computed with either a view transform or a Skia shader
|
|
20
|
+
// on top of it: the transform is affine (one coefficient for the whole lens — that's a
|
|
21
|
+
// magnifying glass, not a lens), and a Skia canvas can't see the native backdrop's pixels.
|
|
22
|
+
// `RenderEffect.createRuntimeShaderEffect` is the only way on Android to hand an AGSL shader
|
|
23
|
+
// content the view has ALREADY drawn: a backdrop snapshot arrives here from the BlurView child,
|
|
24
|
+
// and the shader samples it at a shifted coordinate.
|
|
25
|
+
//
|
|
26
|
+
// The shader source arrives as a PROP from JS (`lens-shader.ts`): AGSL and SKSL are one
|
|
27
|
+
// language, so the lens's and the surface's geometry are literally the same string. Keeping a
|
|
28
|
+
// second copy of the SDF here would be exactly the drift this phase closes off.
|
|
29
|
+
@SuppressLint("ViewConstructor")
|
|
30
|
+
class GlassLensView(context: Context, appContext: AppContext) : ExpoView(context, appContext) {
|
|
31
|
+
private val density = context.resources.displayMetrics.density
|
|
32
|
+
|
|
33
|
+
/** Lightness, variegation and color of the backdrop under this glass. The app listens to this
|
|
34
|
+
* event to recolor the ink once the glass has worked past its own limit (see the probe in
|
|
35
|
+
* GlassBackdropView). */
|
|
36
|
+
private val onBackdropSample by EventDispatcher()
|
|
37
|
+
|
|
38
|
+
private val screenAt = IntArray(2)
|
|
39
|
+
private val selfAt = IntArray(2)
|
|
40
|
+
private val backdropAt = IntArray(2)
|
|
41
|
+
|
|
42
|
+
// A ViewGroup doesn't draw itself by default, and drawing the backdrop is now this view's job.
|
|
43
|
+
init { setWillNotDraw(false) }
|
|
44
|
+
|
|
45
|
+
private var backdrop: GlassBackdropView? = null
|
|
46
|
+
|
|
47
|
+
/** Tag of the TARGET (the app's own blur-target wrapper), not of the capture itself: a
|
|
48
|
+
* fallback wrapper sits outside for below Android 13, and our GlassBackdropView lies inside
|
|
49
|
+
* it. */
|
|
50
|
+
var backdropId: Int? = null
|
|
51
|
+
set(value) {
|
|
52
|
+
if (field == value) return
|
|
53
|
+
field = value
|
|
54
|
+
backdrop?.unregister(this)
|
|
55
|
+
backdrop = null
|
|
56
|
+
attach(value, RESOLVE_TRIES)
|
|
57
|
+
invalidate()
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Looks for the capture view with retries across frames: the tag arrives before the target
|
|
61
|
+
* manages to register, and the setter won't let a second set of the same tag through anyway. */
|
|
62
|
+
private fun attach(id: Int?, tries: Int) {
|
|
63
|
+
if (id == null || id != backdropId || backdrop != null) return
|
|
64
|
+
backdrop = appContext.findView<View>(id)?.let(::findBackdrop)
|
|
65
|
+
if (backdrop != null) {
|
|
66
|
+
backdrop?.register(this)
|
|
67
|
+
invalidate()
|
|
68
|
+
return
|
|
69
|
+
}
|
|
70
|
+
if (tries > 0) {
|
|
71
|
+
postOnAnimation { attach(id, tries - 1) }
|
|
72
|
+
return
|
|
73
|
+
}
|
|
74
|
+
Log.e("GlassLens", "backdrop $id not found, glass is left without refraction")
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
private fun findBackdrop(view: View, depth: Int = 0): GlassBackdropView? {
|
|
78
|
+
if (view is GlassBackdropView) return view
|
|
79
|
+
if (depth >= 3 || view !is ViewGroup) return null
|
|
80
|
+
for (i in 0 until view.childCount) {
|
|
81
|
+
findBackdrop(view.getChildAt(i), depth + 1)?.let { return it }
|
|
82
|
+
}
|
|
83
|
+
return null
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
private var shader: RuntimeShader? = null
|
|
87
|
+
private var compiledSource: String? = null
|
|
88
|
+
|
|
89
|
+
var shaderSource: String? = null
|
|
90
|
+
|
|
91
|
+
/** Size of the VISIBLE glass in dp: the lightness probe's rectangle is computed from it.
|
|
92
|
+
* Everything else about the material arrives through the shared channel below. */
|
|
93
|
+
var glassWidth = 0f
|
|
94
|
+
var glassHeight = 0f
|
|
95
|
+
|
|
96
|
+
// THE SHARED UNIFORM CHANNEL. There used to be a separate Prop per material value, and adding
|
|
97
|
+
// a phenomenon to the shader meant a Kotlin edit, an APK rebuild, and — on a typo — the lens
|
|
98
|
+
// silently turning off: Expo swallows an unknown prop without a word (material-lab.md E-01,
|
|
99
|
+
// which is how refraction ended up disabled in production for an entire phase). Now name, size
|
|
100
|
+
// and value arrive TOGETHER, and a mismatch with the shader gets logged by name.
|
|
101
|
+
var uniformNames: List<String> = emptyList()
|
|
102
|
+
var uniformValues: List<Double> = emptyList()
|
|
103
|
+
var uniformSizes: List<Int> = emptyList()
|
|
104
|
+
|
|
105
|
+
// Compiling AGSL is expensive and must only happen when the source actually changes: props
|
|
106
|
+
// arrive in a batch on every render, while the string itself stays the same.
|
|
107
|
+
private fun ensureShader(): RuntimeShader? {
|
|
108
|
+
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return null
|
|
109
|
+
val src = shaderSource ?: return null
|
|
110
|
+
if (src == compiledSource) return shader
|
|
111
|
+
compiledSource = src
|
|
112
|
+
// A compile error arrives as an exception from the constructor and crashes the view if left
|
|
113
|
+
// uncaught. But it can't be silent either: without the effect, BlurView draws its own
|
|
114
|
+
// RECTANGLE across the whole view, and the symptom looks nothing like the error text.
|
|
115
|
+
shader = try {
|
|
116
|
+
RuntimeShader(src)
|
|
117
|
+
} catch (e: Throwable) {
|
|
118
|
+
Log.e("GlassLens", "AGSL failed to compile, lens is disabled", e)
|
|
119
|
+
null
|
|
120
|
+
}
|
|
121
|
+
return shader
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** The single call made after any prop changes — uniforms only take effect through a fresh
|
|
125
|
+
* `setRenderEffect`; mutating the `RuntimeShader` itself doesn't invalidate the view. */
|
|
126
|
+
fun applyEffect() {
|
|
127
|
+
// The view can ONLY be hidden when there's no shader at all: the BlurView inside stops
|
|
128
|
+
// capturing the backdrop if hidden, and doesn't come back to life on its own. A transitional
|
|
129
|
+
// state just waits for the next call.
|
|
130
|
+
val effect = ensureShader()
|
|
131
|
+
if (effect == null) {
|
|
132
|
+
visibility = INVISIBLE
|
|
133
|
+
return
|
|
134
|
+
}
|
|
135
|
+
visibility = VISIBLE
|
|
136
|
+
if (width <= 0 || height <= 0 || glassWidth <= 0f || glassHeight <= 0f) return
|
|
137
|
+
|
|
138
|
+
try {
|
|
139
|
+
// The view sets ONLY what it alone knows: its own size and its own place on screen.
|
|
140
|
+
effect.setFloatUniform("u_center", width / 2f, height / 2f)
|
|
141
|
+
// How far content actually extends: the lens view is wider than the glass by a margin,
|
|
142
|
+
// beyond that it's empty.
|
|
143
|
+
effect.setFloatUniform("u_reach", min(width, height) / 2f)
|
|
144
|
+
|
|
145
|
+
// The capture ends at the screen's edge, while the surface's lens view runs its full width
|
|
146
|
+
// past it. Hand the shader the rectangle where content actually exists, in ITS OWN
|
|
147
|
+
// coordinates — otherwise a band along such edges is left without refraction.
|
|
148
|
+
getLocationOnScreen(screenAt)
|
|
149
|
+
val metrics = resources.displayMetrics
|
|
150
|
+
val minX = maxOf(-screenAt[0], 0).toFloat() + 1f
|
|
151
|
+
val minY = maxOf(-screenAt[1], 0).toFloat() + 1f
|
|
152
|
+
val maxX = minOf(metrics.widthPixels - screenAt[0], width).toFloat() - 1f
|
|
153
|
+
val maxY = minOf(metrics.heightPixels - screenAt[1], height).toFloat() - 1f
|
|
154
|
+
effect.setFloatUniform("u_contentMin", minX, minY)
|
|
155
|
+
effect.setFloatUniform("u_contentMax", maxX, maxY)
|
|
156
|
+
// Backdrop estimate under the glass. It can't be computed in the shader from a handful of
|
|
157
|
+
// taps: body density is a nonlinear function of lightness, and at the kink the estimate's
|
|
158
|
+
// spread between neighboring pixels used to turn into GHOST COPIES of text lying under the
|
|
159
|
+
// glass, one sampling radius away. Here it's one value for the whole surface, coming from
|
|
160
|
+
// the probe and smoothed over time — there's nowhere for a comb artifact to come from.
|
|
161
|
+
effect.setFloatUniform("u_probeLuma", probeLuma)
|
|
162
|
+
effect.setFloatUniform("u_probeBusy", probeBusy)
|
|
163
|
+
effect.setFloatUniform("u_probeRange", probeLo, probeHi)
|
|
164
|
+
effect.setFloatUniform("u_probeSlope", probeSlopeX, probeSlopeY)
|
|
165
|
+
effect.setFloatUniform("u_probe", probeR, probeG, probeB)
|
|
166
|
+
applyChannel(effect)
|
|
167
|
+
} catch (e: Throwable) {
|
|
168
|
+
Log.e("GlassLens", "lens uniforms drifted out of sync with the shader", e)
|
|
169
|
+
setRenderEffect(null)
|
|
170
|
+
return
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
setRenderEffect(RenderEffect.createRuntimeShaderEffect(effect, "content"))
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** The shared channel: name, size and values are already reconciled on the JS side
|
|
177
|
+
* (`adapters.ts`). The error isn't swallowed — otherwise the lens turns off silently and the
|
|
178
|
+
* symptom looks like nothing in particular. */
|
|
179
|
+
private fun applyChannel(effect: RuntimeShader) {
|
|
180
|
+
var at = 0
|
|
181
|
+
for (k in uniformNames.indices) {
|
|
182
|
+
val name = uniformNames[k]
|
|
183
|
+
val size = uniformSizes.getOrElse(k) { 1 }
|
|
184
|
+
if (at + size > uniformValues.size) break
|
|
185
|
+
try {
|
|
186
|
+
val v0 = uniformValues[at].toFloat()
|
|
187
|
+
when (size) {
|
|
188
|
+
1 -> effect.setFloatUniform(name, v0)
|
|
189
|
+
2 -> effect.setFloatUniform(name, v0, uniformValues[at + 1].toFloat())
|
|
190
|
+
3 -> effect.setFloatUniform(
|
|
191
|
+
name,
|
|
192
|
+
v0,
|
|
193
|
+
uniformValues[at + 1].toFloat(),
|
|
194
|
+
uniformValues[at + 2].toFloat(),
|
|
195
|
+
)
|
|
196
|
+
4 -> effect.setFloatUniform(
|
|
197
|
+
name,
|
|
198
|
+
v0,
|
|
199
|
+
uniformValues[at + 1].toFloat(),
|
|
200
|
+
uniformValues[at + 2].toFloat(),
|
|
201
|
+
uniformValues[at + 3].toFloat(),
|
|
202
|
+
)
|
|
203
|
+
}
|
|
204
|
+
} catch (e: Throwable) {
|
|
205
|
+
Log.e("GlassLens", "uniform $name (size $size) was rejected by the shader", e)
|
|
206
|
+
}
|
|
207
|
+
at += size
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** The lens's content is a backdrop snapshot, and it draws it itself: `RenderEffect` works off
|
|
212
|
+
* what the view has drawn. The offset is taken from screen position — it accounts for
|
|
213
|
+
* transforms, and when the glass moves, a different patch of backdrop ends up underneath it. */
|
|
214
|
+
override fun onDraw(canvas: Canvas) {
|
|
215
|
+
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return
|
|
216
|
+
val source = backdrop ?: return
|
|
217
|
+
// Registration is idempotent and self-healing: the backdrop can reconnect.
|
|
218
|
+
source.register(this)
|
|
219
|
+
val n = source.content ?: return
|
|
220
|
+
getLocationOnScreen(selfAt)
|
|
221
|
+
source.getLocationOnScreen(backdropAt)
|
|
222
|
+
canvas.save()
|
|
223
|
+
canvas.translate((backdropAt[0] - selfAt[0]).toFloat(), (backdropAt[1] - selfAt[1]).toFloat())
|
|
224
|
+
canvas.drawRenderNode(n)
|
|
225
|
+
canvas.restore()
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** The probe has taken a fresh grid. Pull OUR OWN rectangle out of it — the tab bar and the
|
|
229
|
+
* mini-player have different things underneath them — and report outward only if it moved
|
|
230
|
+
* noticeably: an event into JS costs more than the measurement itself. */
|
|
231
|
+
fun onBackdropProbed() {
|
|
232
|
+
val source = backdrop ?: return
|
|
233
|
+
val luma = source.lumaGrid ?: return
|
|
234
|
+
val color = source.colorGrid ?: return
|
|
235
|
+
if (source.width <= 0 || source.height <= 0) return
|
|
236
|
+
|
|
237
|
+
getLocationOnScreen(selfAt)
|
|
238
|
+
source.getLocationOnScreen(backdropAt)
|
|
239
|
+
val halfW = glassWidth * density / 2f
|
|
240
|
+
val halfH = glassHeight * density / 2f
|
|
241
|
+
val cx = (selfAt[0] - backdropAt[0]) + width / 2f
|
|
242
|
+
val cy = (selfAt[1] - backdropAt[1]) + height / 2f
|
|
243
|
+
|
|
244
|
+
val gw = GlassBackdropView.PROBE_W
|
|
245
|
+
val gh = GlassBackdropView.PROBE_H
|
|
246
|
+
val sx = gw / source.width.toFloat()
|
|
247
|
+
val sy = gh / source.height.toFloat()
|
|
248
|
+
val x0 = max(((cx - halfW) * sx).toInt(), 0)
|
|
249
|
+
val x1 = min(((cx + halfW) * sx).toInt() + 1, gw)
|
|
250
|
+
val y0 = max(((cy - halfH) * sy).toInt(), 0)
|
|
251
|
+
val y1 = min(((cy + halfH) * sy).toInt() + 1, gh)
|
|
252
|
+
if (x1 <= x0 || y1 <= y0) return
|
|
253
|
+
|
|
254
|
+
var sum = 0f
|
|
255
|
+
var r = 0f
|
|
256
|
+
var g = 0f
|
|
257
|
+
var b = 0f
|
|
258
|
+
var count = 0
|
|
259
|
+
val bins = IntArray(BINS)
|
|
260
|
+
// Lightness slope across the surface. Tinting must be GRADIENT: where one half under the
|
|
261
|
+
// glass is light and the other dark, one density for the whole element gives no separation
|
|
262
|
+
// on either half. A plane is the crudest model that describes this, and the only one that's
|
|
263
|
+
// smooth by construction: a point estimate at the density kink used to turn into ghost
|
|
264
|
+
// copies of text (material-lab.md E-36).
|
|
265
|
+
var sumU = 0f
|
|
266
|
+
var sumV = 0f
|
|
267
|
+
var uu = 0f
|
|
268
|
+
var vv = 0f
|
|
269
|
+
val midX = (x0 + x1 - 1) * 0.5f
|
|
270
|
+
val midY = (y0 + y1 - 1) * 0.5f
|
|
271
|
+
val halfCellsX = max((x1 - 1 - x0) * 0.5f, 0.5f)
|
|
272
|
+
val halfCellsY = max((y1 - 1 - y0) * 0.5f, 0.5f)
|
|
273
|
+
for (y in y0 until y1) {
|
|
274
|
+
for (x in x0 until x1) {
|
|
275
|
+
val i = y * gw + x
|
|
276
|
+
val l = luma[i]
|
|
277
|
+
sum += l
|
|
278
|
+
bins[((l * (BINS - 1)).toInt()).coerceIn(0, BINS - 1)]++
|
|
279
|
+
val u = (x - midX) / halfCellsX
|
|
280
|
+
val v = (y - midY) / halfCellsY
|
|
281
|
+
sumU += l * u
|
|
282
|
+
sumV += l * v
|
|
283
|
+
uu += u * u
|
|
284
|
+
vv += v * v
|
|
285
|
+
r += color[i * 3]
|
|
286
|
+
g += color[i * 3 + 1]
|
|
287
|
+
b += color[i * 3 + 2]
|
|
288
|
+
count++
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
val n = count.toFloat()
|
|
292
|
+
val mean = sum / n
|
|
293
|
+
// The coordinates are centered, so the normal equations decouple: the slope along each axis
|
|
294
|
+
// is computed independently.
|
|
295
|
+
val slopeX = if (uu > 0.001f) sumU / uu else 0f
|
|
296
|
+
val slopeY = if (vv > 0.001f) sumV / vv else 0f
|
|
297
|
+
|
|
298
|
+
// The edges are taken as PERCENTILES, not min/max: a single white dot under the glass's edge
|
|
299
|
+
// shouldn't read as "there's a white field under the glass." Judging by the average is even
|
|
300
|
+
// worse — right over a black/white border it gives a gray that's formally fine, while the
|
|
301
|
+
// ink drowns over the light half.
|
|
302
|
+
var lo = 0f
|
|
303
|
+
var hi = 1f
|
|
304
|
+
var acc = 0
|
|
305
|
+
val loAt = (count * 0.1f).toInt()
|
|
306
|
+
val hiAt = (count * 0.9f).toInt()
|
|
307
|
+
var loSet = false
|
|
308
|
+
for (k in 0 until BINS) {
|
|
309
|
+
val next = acc + bins[k]
|
|
310
|
+
if (!loSet && next > loAt) {
|
|
311
|
+
lo = k / (BINS - 1f)
|
|
312
|
+
loSet = true
|
|
313
|
+
}
|
|
314
|
+
if (next > hiAt) {
|
|
315
|
+
hi = k / (BINS - 1f)
|
|
316
|
+
break
|
|
317
|
+
}
|
|
318
|
+
acc = next
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// Variegation is the mean deviation from average lightness, doubled (a field that's half
|
|
322
|
+
// black and half white comes out to exactly 1). Range doesn't work for this: a page of dark
|
|
323
|
+
// text gets the same value as a checkerboard, even though light makes up a fraction of a
|
|
324
|
+
// percent of it.
|
|
325
|
+
var dev = 0f
|
|
326
|
+
for (k in 0 until BINS) {
|
|
327
|
+
dev += bins[k] * kotlin.math.abs(k / (BINS - 1f) - mean)
|
|
328
|
+
}
|
|
329
|
+
val busy = (2f * dev / n).coerceIn(0f, 1f)
|
|
330
|
+
|
|
331
|
+
// The smoothing target. The value itself travels to it frame by frame (see `settle`): the
|
|
332
|
+
// probe's step is 180 ms, and without in-between frames the adaptation reads as steps.
|
|
333
|
+
targetLuma = mean
|
|
334
|
+
targetBusy = busy
|
|
335
|
+
targetLo = lo
|
|
336
|
+
targetHi = hi
|
|
337
|
+
targetSlopeX = slopeX
|
|
338
|
+
targetSlopeY = slopeY
|
|
339
|
+
targetR = r / n
|
|
340
|
+
targetG = g / n
|
|
341
|
+
targetB = b / n
|
|
342
|
+
if (probeLuma < 0f) {
|
|
343
|
+
probeLuma = mean
|
|
344
|
+
probeBusy = busy
|
|
345
|
+
probeLo = lo
|
|
346
|
+
probeHi = hi
|
|
347
|
+
probeSlopeX = slopeX
|
|
348
|
+
probeSlopeY = slopeY
|
|
349
|
+
probeR = targetR
|
|
350
|
+
probeG = targetG
|
|
351
|
+
probeB = targetB
|
|
352
|
+
}
|
|
353
|
+
settle()
|
|
354
|
+
|
|
355
|
+
// The event fires on EVERY measurement, not only on change. There used to be a threshold
|
|
356
|
+
// here, and on a static screen events stopped altogether — while the decision to recolor
|
|
357
|
+
// content needs several confirmations in a row and so never accumulated any. The rate is
|
|
358
|
+
// already low (PROBE_INTERVAL_MS); filtering it is the consumer's job.
|
|
359
|
+
emitSample(mean, busy, lo, hi, r / n, g / n, b / n)
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
private fun emitSample(
|
|
363
|
+
luma: Float,
|
|
364
|
+
busy: Float,
|
|
365
|
+
lo: Float,
|
|
366
|
+
hi: Float,
|
|
367
|
+
r: Float,
|
|
368
|
+
g: Float,
|
|
369
|
+
b: Float,
|
|
370
|
+
) {
|
|
371
|
+
onBackdropSample(
|
|
372
|
+
mapOf("luma" to luma, "busy" to busy, "lo" to lo, "hi" to hi, "r" to r, "g" to g, "b" to b),
|
|
373
|
+
)
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
override fun onAttachedToWindow() {
|
|
377
|
+
super.onAttachedToWindow()
|
|
378
|
+
attach(backdropId, RESOLVE_TRIES)
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
override fun onDetachedFromWindow() {
|
|
382
|
+
backdrop?.unregister(this)
|
|
383
|
+
super.onDetachedFromWindow()
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
|
|
387
|
+
super.onSizeChanged(w, h, oldw, oldh)
|
|
388
|
+
applyEffect()
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
// Smoothed backdrop estimate. Negative lightness = the probe hasn't reported anything yet,
|
|
393
|
+
// and the shader falls back on its own samples.
|
|
394
|
+
private var probeLuma = -1f
|
|
395
|
+
private var probeBusy = 0f
|
|
396
|
+
private var probeLo = 0f
|
|
397
|
+
private var probeHi = 0f
|
|
398
|
+
private var probeSlopeX = 0f
|
|
399
|
+
private var probeSlopeY = 0f
|
|
400
|
+
private var probeR = 0f
|
|
401
|
+
private var probeG = 0f
|
|
402
|
+
private var probeB = 0f
|
|
403
|
+
private var targetLuma = 0f
|
|
404
|
+
private var targetBusy = 0f
|
|
405
|
+
private var targetLo = 0f
|
|
406
|
+
private var targetHi = 0f
|
|
407
|
+
private var targetSlopeX = 0f
|
|
408
|
+
private var targetSlopeY = 0f
|
|
409
|
+
private var targetR = 0f
|
|
410
|
+
private var targetG = 0f
|
|
411
|
+
private var targetB = 0f
|
|
412
|
+
private var settling = false
|
|
413
|
+
|
|
414
|
+
/** Exponential approach to the latest measurement, frame by frame. Stops on its own once
|
|
415
|
+
* there's nowhere left to go: while the value is holding still, the effect isn't recreated
|
|
416
|
+
* at all. */
|
|
417
|
+
private fun settle() {
|
|
418
|
+
if (settling) return
|
|
419
|
+
settling = true
|
|
420
|
+
post(object : Runnable {
|
|
421
|
+
override fun run() {
|
|
422
|
+
// The condition looks at EVERY quantity the loop moves: on luma alone it used to stall
|
|
423
|
+
// when only the hue of the backdrop changed. Maximum, not sum: the threshold is set for
|
|
424
|
+
// ONE quantity and summing would tighten it with every added term — extra frames of
|
|
425
|
+
// `applyEffect()` for nothing.
|
|
426
|
+
val d = maxOf(
|
|
427
|
+
maxOf(abs(targetLuma - probeLuma), abs(targetBusy - probeBusy), abs(targetLo - probeLo)),
|
|
428
|
+
maxOf(abs(targetHi - probeHi), abs(targetSlopeX - probeSlopeX), abs(targetSlopeY - probeSlopeY)),
|
|
429
|
+
maxOf(abs(targetR - probeR), abs(targetG - probeG), abs(targetB - probeB)),
|
|
430
|
+
)
|
|
431
|
+
probeLuma += (targetLuma - probeLuma) * SETTLE
|
|
432
|
+
probeBusy += (targetBusy - probeBusy) * SETTLE
|
|
433
|
+
probeLo += (targetLo - probeLo) * SETTLE
|
|
434
|
+
probeHi += (targetHi - probeHi) * SETTLE
|
|
435
|
+
probeSlopeX += (targetSlopeX - probeSlopeX) * SETTLE
|
|
436
|
+
probeSlopeY += (targetSlopeY - probeSlopeY) * SETTLE
|
|
437
|
+
probeR += (targetR - probeR) * SETTLE
|
|
438
|
+
probeG += (targetG - probeG) * SETTLE
|
|
439
|
+
probeB += (targetB - probeB) * SETTLE
|
|
440
|
+
applyEffect()
|
|
441
|
+
if (d > SETTLE_EPS) postOnAnimation(this) else settling = false
|
|
442
|
+
}
|
|
443
|
+
})
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
private companion object {
|
|
447
|
+
/** How many frames to wait for the capture view to appear before declaring it absent. */
|
|
448
|
+
const val RESOLVE_TRIES = 10
|
|
449
|
+
|
|
450
|
+
/** Number of histogram bins. Enough for both percentiles and mean deviation: the statistics
|
|
451
|
+
* don't need to be any more precise than this — they drive smoothly varying quantities. */
|
|
452
|
+
const val BINS = 32
|
|
453
|
+
|
|
454
|
+
/** Fraction of the way to the target covered per frame. A time constant of roughly a third
|
|
455
|
+
* of a second — the adaptation has to be unnoticeable, not instant. */
|
|
456
|
+
const val SETTLE = 0.08f
|
|
457
|
+
|
|
458
|
+
/** Below this difference, the value is considered to have arrived, and the frame-by-frame
|
|
459
|
+
* approach stops. */
|
|
460
|
+
const val SETTLE_EPS = 0.002f
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
package expo.modules.glasslens
|
|
2
|
+
|
|
3
|
+
import android.annotation.SuppressLint
|
|
4
|
+
import android.content.Context
|
|
5
|
+
import android.graphics.RenderEffect
|
|
6
|
+
import android.graphics.RuntimeShader
|
|
7
|
+
import android.os.Build
|
|
8
|
+
import android.util.Log
|
|
9
|
+
import expo.modules.kotlin.AppContext
|
|
10
|
+
import expo.modules.kotlin.views.ExpoView
|
|
11
|
+
|
|
12
|
+
// A diagnostic probe for one question: WHICH pixels actually reach the
|
|
13
|
+
// `RenderEffect.createRuntimeShaderEffect` shader. The documentation says "the content of the
|
|
14
|
+
// RenderNode the effect is attached to, together with its children," but all of VireGlass's
|
|
15
|
+
// architecture rests on that claim, so it's checked by experiment, not by quoting the docs.
|
|
16
|
+
//
|
|
17
|
+
// The shader is deliberately trivial: it returns the sample as-is, with no optics. Whatever
|
|
18
|
+
// shows up on screen is exactly what the shader received.
|
|
19
|
+
//
|
|
20
|
+
// Doesn't touch the production GlassLensView: a separate class, a separate view name.
|
|
21
|
+
private const val PROBE_AGSL = """
|
|
22
|
+
uniform shader content;
|
|
23
|
+
uniform float2 u_size;
|
|
24
|
+
uniform float u_mode;
|
|
25
|
+
|
|
26
|
+
half4 main(float2 xy) {
|
|
27
|
+
half4 c = content.eval(xy);
|
|
28
|
+
|
|
29
|
+
// mode 0 — raw sample: shows exactly what arrived in the shader.
|
|
30
|
+
if (u_mode < 0.5) { return c; }
|
|
31
|
+
|
|
32
|
+
// mode 1 — shader-liveness marker: diagonal hatching over the sample.
|
|
33
|
+
// Tells "the shader got transparency" apart from "the shader never applied at all."
|
|
34
|
+
if (u_mode < 1.5) {
|
|
35
|
+
float stripe = step(0.5, fract((xy.x + xy.y) / 40.0));
|
|
36
|
+
half3 marker = half3(1.0, 0.0, 1.0) * half(stripe * 0.35);
|
|
37
|
+
return half4(c.rgb + marker * (1.0 - c.a), max(c.a, half(stripe * 0.35)));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// mode 2 — the sample's alpha map, opaque. An unambiguous answer to question §9: white =
|
|
41
|
+
// opaque content reached the shader, black = nothing reached it at all. A midtone within
|
|
42
|
+
// the view's bounds means a semi-transparent capture (a tinted BlurView).
|
|
43
|
+
return half4(c.a, c.a, c.a, 1.0);
|
|
44
|
+
}
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
@SuppressLint("ViewConstructor")
|
|
48
|
+
class GlassProbeView(context: Context, appContext: AppContext) : ExpoView(context, appContext) {
|
|
49
|
+
private val shader = try {
|
|
50
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) RuntimeShader(PROBE_AGSL) else null
|
|
51
|
+
} catch (e: Throwable) {
|
|
52
|
+
Log.e("GlassProbe", "probe AGSL failed to compile", e)
|
|
53
|
+
null
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** 0 — raw sample, 1 — sample + shader-liveness marker. */
|
|
57
|
+
var mode = 0f
|
|
58
|
+
|
|
59
|
+
fun applyEffect() {
|
|
60
|
+
val effect = shader ?: run {
|
|
61
|
+
Log.w("GlassProbe", "no shader — effect not applied")
|
|
62
|
+
return
|
|
63
|
+
}
|
|
64
|
+
if (width <= 0 || height <= 0) return
|
|
65
|
+
|
|
66
|
+
effect.setFloatUniform("u_size", width.toFloat(), height.toFloat())
|
|
67
|
+
effect.setFloatUniform("u_mode", mode)
|
|
68
|
+
setRenderEffect(RenderEffect.createRuntimeShaderEffect(effect, "content"))
|
|
69
|
+
Log.i("GlassProbe", "effect applied: ${width}x$height mode=$mode")
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
|
|
73
|
+
super.onSizeChanged(w, h, oldw, oldh)
|
|
74
|
+
applyEffect()
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Automatic content polarity above the glass.
|
|
3
|
+
*
|
|
4
|
+
* The glass adjusts its own body to the backdrop — but that has a limit. When the backdrop is
|
|
5
|
+
* light AND the ink over it is light, no amount of body density can separate them by contrast
|
|
6
|
+
* anymore: the body hits its own ceiling, and the letters drown. Past that point the decision is
|
|
7
|
+
* no longer the glass's to make but the app's — it recolors the ink to the opposite side, and all
|
|
8
|
+
* of the glass's own automation reflows behind it, because it depends on that same polarity.
|
|
9
|
+
*
|
|
10
|
+
* The app has nowhere to get the lightness of the backdrop UNDER the glass from — only the native
|
|
11
|
+
* capture can see it. That's where it comes from: the `onBackdropSample` event (a probe in
|
|
12
|
+
* `GlassBackdropView`).
|
|
13
|
+
*
|
|
14
|
+
* The decision is made BY LIGHTNESS with hysteresis: computing body density here with the
|
|
15
|
+
* shader's formula would mean keeping a second implementation of the model that silently drifts
|
|
16
|
+
* from the first. Whether the body and the ink actually separate by contrast is checked by
|
|
17
|
+
* `check:optics` — against a real render, not a copy of the formula.
|
|
18
|
+
*/
|
|
19
|
+
type BackdropSample = {
|
|
20
|
+
/** Average lightness of the backdrop under the glass, 0..1, on the same (sRGB-encoded) scale
|
|
21
|
+
* as the screen. */
|
|
22
|
+
luma: number;
|
|
23
|
+
/** Variegation: twice the mean deviation of lightness, 0 on a flat fill, 1 at the limit.
|
|
24
|
+
* Exactly the same quantity the probe computes on the web — otherwise the platforms would
|
|
25
|
+
* drift apart silently. */
|
|
26
|
+
busy: number;
|
|
27
|
+
/** The darkest and lightest spots under the glass. The legibility decision is made from these,
|
|
28
|
+
* not the average: right over a black/white border the average reads as "everything's fine". */
|
|
29
|
+
lo: number;
|
|
30
|
+
hi: number;
|
|
31
|
+
r: number;
|
|
32
|
+
g: number;
|
|
33
|
+
b: number;
|
|
34
|
+
};
|
|
35
|
+
/** The element's ambient color from a probe sample — the one that bleeds into its shadow
|
|
36
|
+
* (docs/reference.md §7). */
|
|
37
|
+
declare function ambientFrom(sample: {
|
|
38
|
+
r: number;
|
|
39
|
+
g: number;
|
|
40
|
+
b: number;
|
|
41
|
+
}): [number, number, number];
|
|
42
|
+
/** Ink lightness at the ends of the scale. The kit keeps light text nearly white and dark text
|
|
43
|
+
* nearly black; there's no in-between polarity state by construction. */
|
|
44
|
+
declare const INK_LIGHT = 0.95;
|
|
45
|
+
declare const INK_DARK = 0.08;
|
|
46
|
+
/**
|
|
47
|
+
* Ink polarity from a backdrop sample — ONE place for every platform. A small element and its
|
|
48
|
+
* glyphs switch between light and dark so that contrast is maximized (docs/reference.md §3): over
|
|
49
|
+
* a yellow flower the reference already has black glyphs on light glass. Holding a light ink with
|
|
50
|
+
* body density over a light backdrop just turns the element into a painted plaque.
|
|
51
|
+
*
|
|
52
|
+
* The decision is by lightness with a bias toward the light side — not by average (a white screen
|
|
53
|
+
* with a dark stripe would otherwise never flip) and not by maximum (one light cover peeking under
|
|
54
|
+
* the edge would recolor the whole panel). Hysteresis lives inside; the caller decides how many
|
|
55
|
+
* confirmations it takes.
|
|
56
|
+
*/
|
|
57
|
+
declare function shouldInkBeLight(sample: {
|
|
58
|
+
luma: number;
|
|
59
|
+
hi?: number;
|
|
60
|
+
}, wasLight: boolean): boolean;
|
|
61
|
+
declare const decisiveLuma: (sample: {
|
|
62
|
+
luma: number;
|
|
63
|
+
hi?: number;
|
|
64
|
+
}) => number;
|
|
65
|
+
/** Lighter than this, a light ink flips to dark. */
|
|
66
|
+
declare const FLIP_LUMA = 0.62;
|
|
67
|
+
/** The way back triggers noticeably earlier than the way forward: without the gap the ink would
|
|
68
|
+
* flicker on every light cover sliding under the glass edge. */
|
|
69
|
+
declare const RETURN_LUMA = 0.5;
|
|
70
|
+
/** How many consecutive samples have to demand a change. The probe samples ~5 times a second, so
|
|
71
|
+
* three samples is roughly half a second of a stable backdrop, not a random cover flashing by
|
|
72
|
+
* under the edge. */
|
|
73
|
+
declare const CONFIRMATIONS = 3;
|
|
74
|
+
/** Duration of the recolor. Slow enough not to read as an event, fast enough that the illegible
|
|
75
|
+
* in-between state doesn't linger noticeably. */
|
|
76
|
+
declare const FADE_MS = 420;
|
|
77
|
+
|
|
78
|
+
export { type BackdropSample as B, CONFIRMATIONS as C, FADE_MS as F, INK_DARK as I, RETURN_LUMA as R, FLIP_LUMA as a, INK_LIGHT as b, ambientFrom as c, decisiveLuma as d, shouldInkBeLight as s };
|