🐲还在人挤人赏龙年烟花?你难道不想拥有一个私人的全景天际盛宴吗?|春节创意投稿

4,995 阅读6分钟

前言

随着春节的脚步越来越近,中国的城市景象也随之变得喜庆热闹,除夕夜的庆典活动逐一展开,烟花大会和无人机灯光秀等成为了璀璨的夜空中最抢眼的亮点。然而,对于像我这样的社交恐惧症患者和居家技术宅来说,云上观赏烟花才是真正的应景之选。

基于这样的原因,借助全景播放器来观赏世界各地的烟花盛况成了不错的选择。虽然全景播放器的开发一般属于OpenGL等图形渲染引擎的范畴,但现在Flutter已经开始对自定义着色器提供支持,这无疑为像我这样的开发者打开了新的大门。

那么,是时候开始搞事了!

效果图

图中的那个白色圆点代表触摸点位置

SVID_20240204_112252_1-ezgif.com-video-to-webp-converter.webp

附带一个全景图片查看功能的版本,支持拖动改变视角:

另外请在能访问github的网络中查看,shader文件我放在github上了

实现介绍

用Flutter来实现这个全景播放器效果,需要的东西正如前言中提到的,在flutter3.7中引入了fragment shader来支持自定义着色器,这次依靠的就是它了,当然还需要补充一些全景播放器和计算机图形学的知识:

全景播放器有哪些实现方式

全景播放器一般就分为两种类型的,其他的都是基于这两种方案上的改进版本:

  1. 等角度立方体贴图(Equi-Angular Cubemap)

    这东西俗称就是天空盒,这种方案经常用在游戏开发这块,其原理用一张图就能解释:

image.png

简而言之,其实就是球面坐标先转换为立方体的向量,最后映射为纹理上的二维坐标。不过这些转换步骤所需的公式什么的,我相信大家看过一遍维基百科上的概述就都知道,这里就不赘述了🤪

  1. 等距圆柱投影(equidistant cylindrical projection)

image.png

这种格式我们在APP中就经常见到,比如说各种全景照片和视频,其转换过程就比较直接,直接从球面坐标转换为纹理的平面二维坐标

投影

上述提到的过程是一个360球面是如何转换为一张全景图片的过程,反之就是如何将一个全景图片还原为360全景的过程,不过手机屏幕区域毕竟有限,只能看到一定范围内的图像,这时候就需要通过投影计算,来计算视角范围内的每个像素对应于全景图片的位置

如果我们想实现一个全景图片播放器,处理的就是投影的计算过程,下面就跟随一段代码看下这个整体过程:


void main() {
    // 来自flutter的屏幕像素点的坐标
    vec2 fragCoord = FlutterFragCoord().xy;

    // 归一化计算,所谓的归一化就是将屏幕坐标转换到[0, 1]的范围内,这样可以保障在不同分辨率的屏幕上显示效果一致
    vec2 q = fragCoord.xy / resolution.xy;

    // 鼠标坐标,活着叫触摸点坐标,用于后续计算视角来计算投影坐标
    vec4 iMouse = vec4(mouse.x,mouse.y,0.0,0.0);

    // 缩放比例,用于调整视角范围
    vec2 FoVScale = vec2(0.225, 0.2);

    // 初始化视角中心点的偏航角和俯仰角,可以理解为一个视角摄像头的朝向,当然同样是归一化处理的
    vec2 centralPoint = (length(iMouse.xy) < 1e-4) ? vec2(0.25, 0.0) : (iMouse.xy / resolution.xy);

    // 对于等角度立方体贴图(天空盒)来说,这步会计算出所需的立方体贴图向量坐标
    vec3 dir = calcCubeCoordsInGnomonicProjection(q, centralPoint, FoVScale);

    // 将立方体贴图坐标转换为2D全景纹理坐标
    vec2 equirectCoord = sphericalToEquirectangular(dir);

    // 根据输入的纹理坐标,采样对应部分的颜色
    vec3 col = texture(image, equirectCoord).rgb;

    // 调整一下颜色,让它看起来更好看
    col *= 0.25 + 0.75 * pow( 16.0 * q.x * q.y * (1.0 - q.x) * (1.0 - q.y), 0.15 );

    // 输出最终对应点的颜色
    fragColor = vec4(col, 1.0);
}

其整体过程如注释中所述,其实就是flutter会传入屏幕中的一个点,然后找到这个屏幕像素点对应全景图片中的哪个像素点而已。

着色器的优势

通过着色器,我们可以非常方便的实现很多通用效果,比如说这种翻页动画,只需要将手势信息传入,即可实现翻页动画,并且性能非常高:

SVID_20240205_163755_1-ezgif.com-video-to-webp-converter.webp

web端的话,倒是可以通过码上掘金上传一个,不过如果全屏展示的话会很卡……感觉可能是分辨率太高,每次都要重新计算渲染整个页面导致的……这块能否通过

请无视那个很丑的边界阴影,正在研究怎么实现翻页阴影……

除了翻页动画,其他各种复杂动画也可以用这种方式来实现。

当然也存在缺陷

看到这里,有些同学可能触类旁通,想到这么一点:

图片是这么实现的,那原生平台侧的控件,都可以放到flutter中来快速实现全景效果?

很遗憾的是,实际上flutter的fragment shader并不支持读取外部平台纹理,这点也有相关的issue 提到,在这个issue中提问者最终找到的方案是将通过decodeImageFromPixels将图片内容转换为Image,这种开历史倒车的无奈之举,性能可想而知,只能说非常勉强……

在Flutter的AnimatedSampler中也有相关注释:

/// A widget that allows access to a snapshot of the child widgets for painting
/// with a sampler applied to a [FragmentProgram].
///
/// When [enabled] is true, the child widgets will be painted into a texture
/// exposed as a [ui.Image]. This can then be passed to a [FragmentShader]
/// instance via [FragmentShader.setSampler].
///
/// If [enabled] is false, then the child widgets are painted as normal.
///
/// 就是这段!
/// Caveats:
///   * Platform views cannot be captured in a texture. If any are present they
///     will be excluded from the texture. Texture-based platform views are OK.
///
/// Example:
///
/// Providing an image to a fragment shader using
/// [FragmentShader.setImageSampler].
///
/// ```dart
/// Widget build(BuildContext context) {
///   return AnimatedSampler(
///     (ui.Image image, Size size, Canvas canvas) {
///       shader
///         ..setFloat(0, size.width)
///         ..setFloat(1, size.height)
///         ..setImageSampler(0, image);
///       canvas.drawRect(Offset.zero & size, Paint()..shader = shader);
///     },
///     child: widget.child,
///   );
/// }
/// ```
///
/// See also:
///   * [SnapshotWidget], which provides a similar API for the purpose of
///      caching during expensive animations.
class AnimatedSampler extends StatelessWidget {

所以,对于通过video_player播放的视频,我是将VideoPlayer作为AnimatedSampler的child,然后每帧都触发一次刷新,通过这样的方式实现的播放效果,但是这种方式在android和ios上没问题,但是web确是一片黑屏……感觉是渲染方式的问题?

除了这点外,通过glsl的片段着色器代码也不支持太大,好像skia上写死了最大运行行数,实际上遇到的很多很有意思的着色器代码都无法在flutter上运行……

除了这些,在官网上也补充了目前还有哪些部分还未支持,只能说现在只能实现简单的着色效果。

当然陡峭的学习成本应该是最大的缺点了~~难的不是语法,是计算机图形学啊~~

总结

fragment shader的引入可以说很大的拓展了flutter的动画能力,举个例子,掘金上存在不少flutter的3d效果实现相关的文章,但是这回,flutter不仅能实现3D效果,甚至连光影都能给你整出来,例如这样:

当然对我来说,最大的价值是我朝思暮想的翻页动画,这下能超进化了!

本文到此,各位新年快乐,年后再见