广告墙效果模拟
一、起始
公元2022年3月某一天,上海漕河泾某中心大楼内,游戏大厂的前端CocosCreator程序员小写a像往常一样进入公司大楼,走进电梯的那一刻,发现电梯三面广告屏上正播放着不同的视频广告,就像下图中展示的效果一样。
(图1)某大楼电梯广告墙模拟效果图
这个三面广告墙的效果不是类似有些3D跑酷游戏场景中的广告墙。它具备立体感强,灵活度高,资源利用率高,消耗小等诸多优点。于是小写a产生个疑问:“我能在我的CocosCreator 做的2D项目中把这种效果做出来吗?”
二、目标
要使用CocosCreator 2.x实现上述广告墙的效果,需要解决的问题主要有以下2个:
1. 游戏内的视频播放
目标1:视频播放节点与普通的Node节点一样可以任意设置层级,大小,事件响应等。
2. 模拟近大远小的透视效果
目标2:具备透视效果,同时还支持对视频画面做更多后期效果(比如模糊,蒙板,水印等)。
三、行动
接下来,我们来实现上面两个小目标。
从目标1出发,我们需要支持视频播放的节点与普通Node节点一样的特性,因此首先排除了传统的播放方式:使用原生平台接口播放。
从目标2出发,如果使用cc.Sprite组件作为播放视频的节点,就很容易在视频图像上做后期效果的处理。
综合以上两点需求,我们找到了突破口:将每帧视频数据提取出来,使用cc.Sprite显示,再对贴图做后期处理得到想要的效果。因此需求变成了如何将视频帧转换为贴图帧和后期处理。
这里就有了两种处理方式:
a> 将视频格式转换为webp的视频格式,即可按帧读取视频数据,再生成SpriteFrame给cc.Sprite显示。
b> 通过ffmpeg库按帧读取mp4等视频格式的帧数据,再生成SpriteFrame给cc.Sprite显示。
可以看到,以上两种方式基本类似,差别在于源视频的格式不同,这里小写a以a方式(webp的视频格式)为例来实现目标效果(实际相比ffmpeg,webp的实现要更简单)。
四、实现
总体设计:
1. 读取webp的视频帧
在ts中实现webp播放组件:WebpPlayer.ts,以下是部分代码。
export default WebpPlayer extends cc.Component{
// 省略不重要的代码
private _spriteModel:cc.Sprite = null
private _texture:cc.Texture2D = null
private _webpdecoder = null
private _imagearray = null
public static create( node:cc.Node ) : WebpPlayer {
if (!cc.isValid(node)) {
return null
}
let webp = node.getComponent(WebpPlayer)
if (!webp) {
webp = node.addComponent(WebpPlayer)
}
let sprite = node.getComponent(cc.Sprite)
if (!sprite) {
sprite = node.addComponent(cc.Sprite)
}
webp.init(sprite)
return webp
}
protected init(sprite:cc.Sprite) {
this._spriteModel = sprite
}
public playWebp( res:string, repeatCount:number = 1 ) : boolean {
let webpAsset = cc.resources.getInfoWithPath(res, cc.Asset);
if (!webpAsset) {
return false;
}
let webpAssetUrl = cc.assetManager.utils.getUrlWithUuid(webpAsset.uuid, {isNative: true, nativeExt: '.webp'})
return this.play(webpAssetUrl,
{adapterScreen:ENUMWebpAdaptScreenType.min_adapt,
repeatCount:repeatCount,}
)
}
public play(webpPath:string, param:EnterParams, callback:WEBP_CALLBACK = null) : boolean {
if (param) {
this._adapterScreenType = param.adapterScreen || ENUMWebpAdaptScreenType.min_adapt
this._startFrame = param.startFrame || 0
this._endFrame = param.endFrame || -1
this._repeatCount = param.repeatCount || 1
if (param.alignToScreen) {
this.alignToScreen()
}
} else {
this._repeatCount = 1
}
this._curRepeatIndex = 0
this._webpCallback = callback
if (this._webpPath != webpPath) {
this._destroyNode()
}
return cc.sys.isNative ? this.playForNative(webpPath) : this.playForWeb(webpPath)
}
// 省略不重要的代码
}
1.1 web:通过第三方库libwebp.js(+demux)来读取。
注:这里只是验证效果,未使用效率更好的webassembly库。
_readyForWeb & toload(加载webp文件)
protected _readyForWeb(webpPath:string, callback: Function) {
if (!this._webpdecoder) {
let self = this
let request = new XMLHttpRequest()
request.open("GET", webpPath, true)
request.responseType = "arraybuffer"
request.onload = function () {
switch (request.status) {
case 200:
self.toload(request.response, callback)
break
default:
if (callback) {
callback(request.status)
}
break
}
}
request.send(null)
} else if (callback) {
callback()
}
}
protected toload(arrData: any, callback: Function) {
if (!this._webpdecoder) {
this._webpdecoder = new window['WebPDecoder']()
}
let response = new Uint8Array(arrData)
this._imagearray = window['WebPRiffParser'](response, 0)
this._imagearray['response'] = response
this._maxFrameCount = this._imagearray['frames'].length
if (callback) {
callback()
}
}
doPlayFrameWeb:读取帧数据并填充cc.Texture2D。
protected doPlayFrameWeb( frameIndex:number ) {
let frame = this._imagearray.frames[frameIndex]
let response = this._imagearray['response']
let heightData = [0]
let widthData = [0]
let rgba = this._webpdecoder.WebPDecodeRGBA(response,
frame['src_off'], frame['src_size'], widthData, heightData)
let data = new Uint8Array(rgba)
if (data) {
frame['data'] = data
frame['imgwidth'] = widthData[0]
frame['imgheight'] = heightData[0]
if (!this._texture) {
this._texture = new cc.Texture2D()
}
this._texture.initWithData(frame['data'],
cc.Texture2D.PixelFormat.RGBA8888, widthData[0], heightData[0])
this._onRefreshTexture(frameIndex, this._texture)
} else {
this.onException()
}
// 省略其它代码
}
_onRefreshTexture:更新SpriteFrame数据并显示。
protected _onRefreshTexture(frameIndex: number, tex:cc.Texture2D) {
let spriteFrame = new cc.SpriteFrame()
spriteFrame.setTexture(tex)
this._spriteModel.spriteFrame = spriteFrame
this._curFrameIndex = frameIndex
if (frameIndex >= this._endFrame) {
this.onPlayToEnd()
}
// 省略其它代码
}
1.2 native:通过webp库(webp, webpmux, webpdemux)来读取。
class WebpNode {
protected:
WebpNode();
public:
virtual ~WebpNode();
void release();
// 省略不重要的代码
static WebpNode* create(const std::string& fileName);
bool getFrameData(int index, std::function<void(unsigned char *, size_t)> callback);
protected:
bool initWithFile(const std::string& filename);
private:
std::vector<unsigned char*> _datas;
std::vector<size_t> _lengths;
uint32_t _width;
uint32_t _height;
cocos2d::Image* _image = nullptr;
// 省略不重要的代码
};
initWithFile(c++):解析webp文件帧数及分辨率等数据。
bool WebpNode::initWithFile(const std::string& filename)
{
cocos2d::Data data = cocos2d::FileUtils::getInstance()->getDataFromFile(fullpath);
if (data.isNull())
{
return false;
}
WebPData webData = { data.getBytes(), (size_t)data.getSize() };
WebPDemuxer* demux = WebPDemux(&webData);
_width = WebPDemuxGetI(demux, WEBP_FF_CANVAS_WIDTH);
_height = WebPDemuxGetI(demux, WEBP_FF_CANVAS_HEIGHT);
WebPIterator iter;
if (WebPDemuxGetFrame(demux, 1, &iter)) {
do {
WebPData fData = iter.fragment;
unsigned char* data = new unsigned char[fData.size];
memcpy(data, fData.bytes, fData.size);
_datas.push_back(data);
_lengths.push_back(fData.size);
} while (WebPDemuxNextFrame(&iter));
WebPDemuxReleaseIterator(&iter);
}
WebPDemuxDelete(demux);
// 省略非关键代码
return true;
}
getFrameData(c++):按帧读取每帧数据。由js层读取,callback把每帧数据和长度返回到js层。
bool WebpNode::getFrameData(int index, std::function<void(unsigned char *, size_t)> callback) {
if (index < 0 || index >= _datas.size()) {
return false;
}
if (callback) {
unsigned char* buff = _datas.at(index);
size_t buffLen = _lengths.at(index);
bool ret = _image->initWithWebpData(buff, buffLen);
if (ret) {
callback(_image->getData(), _image->getDataLen());
}
}
return true;
}
实际运行时大家可能会发现,这里有两个内存热点。
a> 每帧解析Image数据时,Image的内存默认会重新new一个_data保存贴图数据,因此这里需要重用Image对象和其中的_data缓冲两个部分,避免每帧创建一张贴图大小的缓冲数据。
bool Image::initWithWebpData(const unsigned char * data, ssize_t dataLen)
{
// 省略非关键代码
auto needLen = _width * _height * (config.input.has_alpha ? 4 : 3);
if ( (_dataLen > 0 && _dataLen != needLen) || _dataLen < 1 || !_data) {
_dataLen = 0;
CC_SAFE_DELETE(_data);
_data = nullptr;
_dataLen = _width * _height * (config.input.has_alpha ? 4 : 3);
_data = static_cast<unsigned char*>(malloc(_dataLen * sizeof(unsigned char)));
}
// 省略非关键代码
}
b> 每一帧贴图的完整数据传递到js层,同样会造成js层出现一帧贴图的大小的数据缓冲(这个对象需要等待gc来释放)。因此这里也需要重用这段缓冲,否则播放视频带来的内存消耗会大幅增长。
static se::Value* s_sharedValue = nullptr;
static bool js_cocos2dx_WebpNode_getFrameData(se::State& s)
{
// 省略非关键代码
auto lambda = [=](unsigned char* szBuff, size_t size) -> void {
se::AutoHandleScope hs;
se::Value dataVal;
CC_UNUSED bool ok = true;
se::ValueArray args;
se::HandleObject retObj(se::Object::createPlainObject());
if (s_sharedValue) {
se::Object* valueObj = s_sharedValue->toObject();
v8::Local<v8::Object> obj = valueObj->_getJSObject();
v8::Local<v8::TypedArray> arrBuf = v8::Local<v8::TypedArray>::Cast(obj);
v8::ArrayBuffer::Contents content = arrBuf->Buffer()->GetContents();
uint8_t* ptr = (uint8_t*)content.Data() + arrBuf->ByteOffset();
size_t byteLength = content.ByteLength();
if (byteLength < size) {
delete s_sharedValue;
s_sharedValue = nullptr;
}
else {
memset(ptr, 0, byteLength);
memcpy(ptr, szBuff, size);
}
}
if (!s_sharedValue) {
s_sharedValue = new se::Value();
cocos2d::Data data;
data.fastSet(szBuff, size);
Data_to_seval(data, s_sharedValue);
data.takeBuffer();
}
retObj->setProperty("data", *s_sharedValue);
args.push_back(se::Value(retObj));
se::Value rval;
se::Object* thisObj = jsThis.isObject() ? jsThis.toObject() : nullptr;
se::Object* funcObj = jsFunc.toObject();
bool succeed = funcObj->call(args, thisObj, &rval);
if (!succeed) {
se::ScriptEngine::getInstance()->clearException();
}
};
// 省略非关键代码
}
SE_BIND_FUNC(js_cocos2dx_WebpNode_getFrameData)
WebpPlayer按帧读取数据并填充cc.Texture2D。
protected doPlayFrameNative(frameIndex:number) {
let self = this
if (!this._videoNative.getFrameData(frameIndex, function (buffList: any) {
let buff = buffList.data
let pixelFormat = self._videoNative.pixelFormat()
if (!self._image) {
self._image = new Image(self._width, self._height)
} else {
self._image.width = self._width
self._image.height = self._height
}
let image = self._image
image._data = buff
image._glFormat = self._glFormat
image._glInternalFormat = self._glInternalFormat
image._glType = self._glType
image._numberOfMipmaps = 0
image._compressed = false
image._bpp = self._bpp
image._premultiplyAlpha = false
image._alignment = 1
image.complete = true
if (!self._texture) {
self._texture = new cc.Texture2D()
}
self._texture.initWithData(image, pixelFormat, self._width, self._height)
self._onRefreshTexture(frameIndex, self._texture)
})) {
this.onException()
}
}
以上即完成双平台的webp读取和显示过程。
1.3 在界面中添加三组Sprite节点及其外框Sprite节点用于显示三面广告墙的视频播放:
(图2)显示三面广告墙的节点及视频外框
添加中间视频的播放代码:
let leftWebp = WebpPlayer.create(this._video_middle_Spr.node)
leftWebp.playWebp("advert-board/videos/game-demo", -1)
播放效果如下:
(图3)播放中间广告墙的效果
2. 模拟侧面显示效果
2.1 修改uv映射
比较简单的方式,我们是通过修改uv映射,把四边形映射成左梯形和右梯形(假设显示效果为等腰梯形),即可模拟出电梯左面和右面的透视效果。因此我们先尝试这种方式,具体图示如下:
(图4)三面广告墙的模拟视觉效果
具体实现步骤:
a> 创建board.effect, board.mtl(绑定board.effect):
(图5)board.mtl的属性上绑定board.effect
b> 按以下方式修改board.effect中的部分代码:
CCEffect %{
techniques:
- passes:
- vert: vs
frag: fs
blendState:
targets:
- blend: true
rasterizerState:
cullMode: none
properties:
texture: { value: white }
offset: { value: 0.1, editor: {range: [0.0, 0.6]} }
transFlag: { value: 1.0}
}%
CCProgram fs %{
// 省略其它代码
void main () {
vec4 o = vec4(1, 1, 1, 1);
vec2 uv = v_uv0;
if (transFlag > 0.0)
uv.y = uv.y + (uv.y - 0.5)*uv.x*offset;
else
uv.y = uv.y + (uv.y - 0.5)*(1.0 - uv.x)*offset;
#if USE_TEXTURE
CCTexture(texture, uv, o);
#endif
if (uv.y < 0.0 || uv.y > 1.0) {
o.a = 0.0;
}
o *= v_color;
ALPHA_TEST(o);
#if USE_BGRA
gl_FragColor = o.bgra;
#else
gl_FragColor = o.rgba;
#endif
}
}%
属性说明:
transFlag :> 0 时变换为右梯形,否则变换为左梯形。
offset :梯形的顶边相对底边的v值差值/2,范围【0,0.6】。
c> 把用来显示视频贴图帧的cc.Sprite组件的Material数组的0下标位置赋值为board.mtl。
(图6)为视频播放节点的cc.Sprite组件设置新的Material
d> 为右侧视频节点设置transFlag及offset参数,并播放视频:
let rightMaterial = this._video_right_Spr.getMaterial(0)
rightMaterial.setProperty("transFlag", -1.0)
rightMaterial.setProperty("offset", 0.5)
let rightFrameMaterial = this._frame_right.getMaterial(0)
rightFrameMaterial.setProperty("transFlag", -1.0)
rightFrameMaterial.setProperty("offset", 0.5)
let rightWebp = WebpPlayer.create(this._video_right_Spr.node)
rightWebp.playWebp("advert-board/videos/game-demo", -1)
得到的右侧广告墙的视频播放静态效果如下:
(图7)电梯右面墙的视频静态效果
效果是差不多了,不过细看之下发现,画面发生扭曲:画面顶边和底边变成曲线了。
动态效果如下:
(图8)电梯广告墙的平板电视被曲面化,物业应该不会同意,吧
仔细分析发现了原因:
uv.y = uv.y + ( uv.y - 0.5)* uv.x *offset;
这个计算的结果是非线性的,因此uv映射的y值就是非线性的,上下两边出现曲线也就在情理之中。
TIPS:到这里,大家会有一个疑问:是不是可以把uv的映射放到vs中,或者使用顶点映射,我的确试过,错得更离谱,这里就不展示结果,有兴趣的可以自己去尝试并思考为什么。
正当小写a同学一愁莫展的时候, 阿蓝 同学假装恰好路过(没错,就是上次写过那篇《2D相机中实现3D翻转》文章的 乐府-阿蓝 ),“Waooh! So cool!,这是什么效果?”。此处省略1000字。然后小写a开心的找到了新方案。
2.2 在2D相机中实现3D翻转
按照公众号《乐府札记》中《2D相机实现3D翻转》文章的方案,最终实现了开头示例中的效果。具体修改如下:
a> 使用新的修改board.effect:删除transFlag, offset属性,增加map_vp属性:
mat_vp: {value:[0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0]}
恢复fs中的代码并修改vs:
CCProgram vs %{
precision highp float;
#include <cc-global>
#include <cc-local>
in vec3 a_position;
in vec4 a_color;
out vec4 v_color;
in vec2 a_uv0;
out vec2 v_uv0;
uniform UNIFORM{
mediump mat4 mat_vp;
};
void main () {
vec4 pos = vec4(a_position, 1);
#if CC_USE_MODEL
pos = mat_vp * cc_matWorld * pos;
#else
pos = mat_vp * pos;
#endif
v_uv0 = a_uv0;
v_color = a_color;
gl_Position = pos;
}
}%
b> 对视频播放节点调用以下方法做初始化:
public static setVPMatToNode(node:cc.Node) {
//计算设备的宽度/高度
let aspect = (cc.view as any)._viewportRect.width / (cc.view as any)._viewportRect.height
//得到视图矩阵matView
let matView:any = cc.mat4()
let matViewInv:any = cc.mat4()
cc.Camera.main.node.getWorldRT(matViewInv)
cc.Mat4.invert(matView, matViewInv)
//得到透视矩阵
let matP:any = cc.mat4()
let fovy = Math.PI / 4
cc.Mat4.perspective(matP, fovy, aspect, 1, 2500)
//VP = 透视矩阵*视图矩阵
let matVP = cc.mat4()
cc.Mat4.multiply(matVP, matP, matView);
let arr = new Float32Array(16);
for (let i=0;i<16;i++){
arr[i]= matVP.m[i]
}
let material = node.getComponent(cc.Sprite).getMaterial(0)
material.setProperty("mat_vp", arr)
}
对视频播放节点调用setVPMatToNode方法设置map_vp属性,并设置旋转角度:
public onLoad() {
// 中间广告屏节点
PerspectiveCamera.setVPMatToNode(this._video_middle_Spr.node)
PerspectiveCamera.setVPMatToNode(this._frame_middle.node)
// 左面广告屏节点
PerspectiveCamera.setVPMatToNode(this._video_left_Spr.node)
this._video_left_Spr.node.rotationY = 90
PerspectiveCamera.setVPMatToNode(this._frame_left.node)
// 右面广告屏节点
PerspectiveCamera.setVPMatToNode(this._video_right_Spr.node)
this._video_right_Spr.node.rotationY = -90
PerspectiveCamera.setVPMatToNode(this._frame_right.node)
}
c> 把视频播放节点都设置为3D,并设置好深度值:
(图9)三个节点上的3D属性和深度值设置
至此大功告成,最终效果图见本文开头图1的展示。
最后,附上另一个游戏中的另一个应用场景展示:
(图10)循环列表上的视频滑动及点选效果(文件大小受限,点选未展示)