
阿裡妹導讀:打開盒馬app,相信你跟阿裡妹一樣,很難抵抗各種美味的誘惑。顔值即正義,盒馬的圖檔視訊技術逼真地還原了食物細節,并在短短數秒内呈現出食物的最佳效果。今天,我們請來阿裡進階無線開發工程師萊甯,解密盒馬app裡那些“美味”視訊是如何生産的。
一、前言
圖檔合成視訊并産生類似PPT中每頁過渡特效的能力是目前很多短視訊軟體帶有的功能,比如抖音的影集。這個功能主要包括圖檔合成視訊、轉場時間線定義和OpenGL特效等三個部分。
其中圖檔轉視訊的流程直接決定了後面過渡特效的實作方案。這裡主要有兩種方案:
- 圖檔預先合成視訊,中間不做處理,記錄每張圖檔展示的時間戳位置,然後在相鄰圖檔切換的時間段用OpenGL做畫面處理。
- 圖檔合成視訊的過程中,在畫面幀寫入時同時做特效處理。
方案1每個流程都比較獨立,更友善實作,但是要重複處理兩次資料,一次合并一次加特效,耗時更長。
方案2的流程是互相穿插的,隻需要處理一次資料,是以我們采用這個方案。
下面主要介紹下幾個重點流程,并以幾個簡單的轉場特效作為例子,示範具體效果。
二、圖檔合成
1.方案
圖檔合成視訊有多種手段可以實作。下面談一下比較常見的幾種技術實作。
I.FFMPEG
定義輸出編碼格式和幀率,然後指定需要處理的圖檔清單即可合成視訊。
ffmpeg -r 1/5 -i img%03d.png -c:v libx264 -vf fps=25 -pix_fmt yuv420p out.mp4
II.MediaCodec
在使用Mediacodec進行視訊轉碼時,需要解碼和編碼兩個codec。解碼視訊後将原始幀資料按照時間戳順序寫入編碼器生成視訊。但是圖檔本身就已經是幀資料,如果将圖檔轉換成YUV資料,然後配合一個自定義的時鐘産生時間戳,不斷将資料寫入編碼器即可達到圖檔轉視訊的效果。
III.MediaCodec&OpenGL
既然Mediacodec合成過程中已經有了處理圖檔資料的流程,可以把這個步驟和特效生成結合起來,把圖檔處理成特效序列幀後再按序寫入編碼器,就能一并生成轉場效果。
2.技術實作
首先需要定義一個時鐘,來控制圖檔幀寫入的頻率和編碼器的時間戳,同時也決定了視訊最終的幀率。
這裡假設需要24fps的幀率,一秒就是1000ms,是以寫入的時間間隔是1000/24=42ms。也就是每隔42ms主動生成一幀資料,然後寫入編碼器。
時間戳需要是遞增的,從0開始,按照前面定義的間隔時間差deltaT,每寫入一次資料後就要将這個時間戳加deltaT,用作下一次寫入。
然後是設定一個EGL環境來調用OpenGL,在Android中一個OpenGl的執行環境是threadlocal的,是以在合成過程中需要一直保持在同一個線程中。Mediacodec的構造函數中有一個surface參數,在編碼器中是用作資料來源。在這個surface中輸入資料就能驅動編碼器生産視訊。通過這個surface用EGL擷取一個EGLSurface,就達到了OpenGL環境和視訊編碼器資料綁定的效果。
這裡不需要手動将圖檔轉換為YUV資料,先把圖檔解碼為bitmap,然後通過texImage2D上傳圖檔紋理到GPU中即可。
最後就是根據圖檔紋理的uv坐标,根據外部時間戳來驅動紋理變化,實作特效。
三、轉場時間線
對于一個圖檔清單,在合成過程中如何銜接前後序列圖檔的展示和過渡時機,決定了最終的視訊效果。
假設有圖檔合集{1,2,3,4},按序合成,可以有如下的時間線:
每個Stage是合成過程中的一個最小單元,首尾的兩個Stage最簡單,隻是單純的顯示圖檔。中間階段的Stage,包括了過渡過程中前後兩張圖檔的展示和過渡動畫的時間戳定義。
假設每張圖檔的展示時間為showT(ms),動畫的時間為animT(ms)。
相鄰Stage中同一張圖的靜态顯示時間的總和為一張圖的總顯示時間,則首尾兩個Stage的有效時長為showT/2,中間的過渡Stage有效時長為showT+animT。
其中過渡動畫的時間段又需要分為:
- 前序退場起始點enterStartT,前序動畫開始時間點。
- 前序退場結束點enterEndT,前序動畫結束時間點。
- 後序入場起始點exitStartT,後序動畫開始時間點。
- 後序入場結束點exitEndT,後序動畫結束時間點。
動畫時間線一般隻定義為非淡入淡出外的其他特效使用。為了過渡的視覺連續性,前後序圖檔的淡入和淡出是貫穿整個動畫時間的。考慮到序列的銜接性,退場完畢後會立刻入場,是以enterEndT=exitStartT。
四、OpenGL特效
1.基礎架構
按照前面時間線定義回調接口,用于處理動畫參數:
//參數初始化
protected abstract void onPhaseInit();
//前序動畫,enterRatio(0-1)
protected abstract void onPhaseEnter(float enterRatio);
//後序動畫,exitRatio(0-1)
protected abstract void onPhaseExit(float exitRatio);
//動畫結束
protected abstract void onPhaseFinish();
//一幀動畫執行完畢,步進
protected abstract void onPhaseStep();
定義幾個通用的片段着色器變量,輔助過渡動畫的處理:
//前序圖檔的紋理
uniform sampler2D preTexture
//後序圖檔的紋理
uniform sampler2D nextTexture;
//過渡動畫總體進度,0到1
uniform float progress;
//視窗的長寬比例
uniform float canvasRatio;
//透明度變化
uniform float canvasAlpha;
前後序列的混合流程,根據動畫流程計算出的兩個紋理的UV坐标混合顔色值:
vec4 fromColor = texture2D(sTexture, fromUv);
vec4 nextColor = texture2D(nextTexture, nextUv);
vec4 mixColor = mix(fromColor, nextColor, mixIntensity);
gl_FragColor = vec4(mixColor.rgb, canvasAlpha);
解析圖檔,先讀取Exif資訊擷取旋轉值,再将旋轉矩陣應用到bitmap上,保證上傳的紋理圖檔與使用者在相冊中看到的旋轉角度是一緻的:
ExifInterface exif = new ExifInterface(imageFile);
orientation = exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL);
int rotation = parseRotation(orientation);
Matrix matrix = new Matrix(rotation);
mImageBitmap = Bitmap.createBitmap(mOriginBitmap, 0, 0, mOriginBitmap.getWidth(), mOriginBitmap.getHeight(), matrix, true);
在使用圖檔之前,還要根據最終的視訊寬高調整OpenGL視窗尺寸。同時紋理的貼圖坐标的起始(0,0)是在紋理坐标系的左下角,而Android系統上canvas坐标原點是在左上角,需要将圖檔做一次y軸的翻轉,不然圖檔上傳後是垂直鏡像。
//根據視窗尺寸生成一個空的bitmap
mCanvasBitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
Canvas bitmapCanvas = new Canvas(mCanvasBitmap);
//翻轉圖檔
bitmapCanvas.scale(1, -1, bitmapCanvas.getWidth() / 2f, bitmapCanvas.getHeight() / 2f);
上傳圖檔紋理,并記錄紋理的handle:
int[] textures = new int[1];
GLES20.glGenTextures(1, textures, 0);
int textureId = textures[0];
GLES20.glBindTexture(textureType, textureId);
GLES20.glTexParameterf(textureType, GLES20.GL_TEXTURE_MIN_FILTER, GLES20.GL_NEAREST);
GLES20.glTexParameterf(textureType, GLES20.GL_TEXTURE_MAG_FILTER, GLES20.GL_LINEAR);
GLES20.glTexParameterf(textureType, GLES20.GL_TEXTURE_WRAP_S, GLES20.GL_CLAMP_TO_EDGE);
GLES20.glTexParameterf(textureType, GLES20.GL_TEXTURE_WRAP_T, GLES20.GL_CLAMP_TO_EDGE);
GLUtils.texImage2D(GLES20.GL_TEXTURE_2D, 0, bitmap, 0);
GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, 0);
加載第二張圖檔時要開啟非0的其他紋理單元,過渡動畫需要同時操作兩個圖檔紋理:
GLES20.glActiveTexture(GLES20.GL_TEXTURE1);
最後是實際繪制的部分,因為用到了透明度漸變,要手動開啟GL_BLEND功能,并注意切換正在操作的紋理:
//清除畫布
GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT | GLES20.GL_DEPTH_BUFFER_BIT);
GLES20.glUseProgram(mProgramHandle);
//綁定頂點坐标
GLES20.glBindBuffer(GLES20.GL_ARRAY_BUFFER, mVertexBufferName);
GLES20.glVertexAttribPointer(getHandle(ATTRIBUTE_VEC4_POSITION), GLConstants.VERTICES_DATA_POS_SIZE, GLES20.GL_FLOAT,
false, GLConstants.VERTICES_DATA_STRIDE_BYTES, GLConstants.VERTICES_DATA_POS_OFFSET);
GLES20.glEnableVertexAttribArray(getHandle(ATTRIBUTE_VEC4_POSITION));
GLES20.glVertexAttribPointer(getHandle(ATTRIBUTE_VEC4_TEXTURE_COORD), GLConstants.VERTICES_DATA_UV_SIZE, GLES20.GL_FLOAT,
false, GLConstants.VERTICES_DATA_STRIDE_BYTES, GLConstants.VERTICES_DATA_UV_OFFSET);
GLES20.glEnableVertexAttribArray(getHandle(ATTRIBUTE_VEC4_TEXTURE_COORD));
//激活有效紋理
GLES20.glActiveTexture(GLES20.GL_TEXTURE0);
//綁定圖檔紋理坐标
GLES20.glBindTexture(targetTexture, texName);
GLES20.glUniform1i(getHandle(UNIFORM_SAMPLER2D_TEXTURE), 0);
//開啟透明度混合
GLES20.glEnable(GLES20.GL_BLEND);
GLES20.glBlendFunc(GLES20.GL_SRC_ALPHA, GLES20.GL_ONE_MINUS_SRC_ALPHA);
//繪制三角形條帶
GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, 4);
//重置環境參數綁定
GLES20.glDisableVertexAttribArray(getHandle(ATTRIBUTE_VEC4_POSITION));
GLES20.glDisableVertexAttribArray(getHandle(ATTRIBUTE_VEC4_TEXTURE_COORD));
GLES20.glBindTexture(targetTexture, 0);
GLES20.glBindBuffer(GLES20.GL_ARRAY_BUFFER, 0);
2.平移覆寫轉場
I.着色器實作
uniform int direction;
void main(void) {
float intensity;
if (direction == 0) {
intensity = step(0.0 + coord.x,progress);
} else if (direction == 1) {
intensity = step(1.0 - coord.x,progress);
} else if (direction == 2) {
intensity = step(1.0 - coord.y,progress);
} else if (direction == 3) {
intensity = step(0.0 + coord.y,progress);
}
vec4 mixColor = mix(fromColor, nextColor, intensity);
}
GLSL中的step函數定義如下,當x
Declaration:
genType step(genType edge, genType x);
Parameters:
edge Specifies the location of the edge of the step function.
x Specify the value to be used to generate the step function.
已知我們有前後兩張圖,将他們覆寫展示。然後從一個方向逐漸修改這一條軸上的所掃過的像素的intensity值,隐藏前圖,展示後圖。經過時鐘動畫驅動後就有了覆寫轉場的效果。
再定義一個direction參數,控制掃描的方向,即可設定不同的轉場方向,有PPT翻頁的效果。
II.效果圖
3.像素化轉場
uniform float squareSizeFactor;
uniform float imageWidthFactor;
uniform float imageHeightFactor;
void main(void) {
float revProgress = (1.0 - progress);
float distFromEdges = min(progress, revProgress);
float squareSize = (squareSizeFactor * distFromEdges) + 1.0;
float dx = squareSize * imageWidthFactor;
float dy = squareSize * imageHeightFactor;
vec2 coord = vec2(dx * floor(uv.x / dx), dy * floor(uv.y / dy));
vec4 fromColor = texture2D(preTexture, coord);
vec4 nextColor = texture2D(nextTexture, coord);
vec4 mixColor = mix(fromColor, nextColor, progress);
};
首先是定義像素塊的效果,我們需要像素塊逐漸變大,到動畫中間值時再逐漸變小到消失。
通過對progress(0到1)取反向值1-progress,得到distFromEdges,可知這個值在progress從0到0.5時會從0到0.5,在0.5到1時會從0.5到0,即達到了我們需要的變大再變小的效果。
像素塊就是一整個方格範圍内的像素都是同一個顔色,視覺效果看起來就形成了明顯的像素間隔。如果我們将一個方格範圍内的紋理坐标都映射為同一個顔色,即實作了像素塊的效果。
squareSizeFactor是影響像素塊大小的一個參數值,設為50,即最大像素塊為50像素。
imageWidthFactor和imageHeightFactor是視窗高寬取倒數,即1/width和1/height。
通過dx floor(uv.x / dx)和dy floor(uv.y / dy)的兩次坐标轉換,就把一個區間範圍内的紋理都映射為了同一個顔色。
4.水波紋特效
I.數學原理
水波紋路的周期變化,實際就是三角函數的一個變種。目前業界最流行的簡易水波紋實作,Adrian的部落格中描述了基本的數學原理:
水波紋實際是Sombero函數的求值,也就是sinc函數的2D版本。
下圖的左邊是sin函數的圖像,右邊是sinc函數的圖像,可以看到明顯的水波紋特征。
部落格中同時提供了一個WebGL版本的着色器實作,不過功能較簡單,隻是做了效果驗證。
将其移植到OpenGLES中,并做參數調整,即可整合到圖檔轉場特效中。
完整的水波紋片段着色器如下:
uniform float mixIntensity;
uniform float rippleTime;
uniform float rippleAmplitude;
uniform float rippleSpeed;
uniform float rippleOffset;
uniform vec2 rippleCenterShift;
void main(void) {
//紋理位置坐标歸一化
vec2 curPosition = -1.0 + 2.0 * vTextureCoord;
//修正相對波紋中心點的位置偏移
curPosition -= rippleCenterShift;
//修正畫面比例
curPosition.x *= canvasRatio;
//計算波紋裡中心點的長度
float centerLength = length(curPosition);
//計算波紋出現的紋理位置
vec2 uv = vTextureCoord + (curPosition/centerLength)*cos(centerLength*rippleAmplitude-rippleTime*rippleSpeed)*rippleOffset;
vec4 fromColor = texture2D(preTexture, uv);
vec4 nextColor = texture2D(nextTexture, uv);
vec4 mixColor = mix(fromColor, nextColor, mixIntensity);
gl_FragColor = vec4(mixColor.rgb, canvasAlpha);
}
其中最關鍵的代碼就是水波紋像素坐标的計算:
vTextureCoord + (curPosition/centerLength)cos(centerLengthrippleAmplitude-rippleTimerippleSpeed)rippleOffset;
簡化一下即:vTextureCoord + Acos(Lx - Ty)rippleOffset,一個标準的餘弦函數。
vTextureCoord是目前紋理的歸一化坐标(0,0)到(1,1)之間。
curPosition是(-1,-1)到(1,1)之間的目前像素坐标。
centerLength是目前點距離波紋中心的距離。
curPosition/centerLength即是線性代數中的機關矢量,這個參數用來決定波紋推動的方向。
cos(centerLengthrippleAmplitude-rippleTimerippleSpeed)通過一個外部時鐘rippleTime來驅動cos函數生成周期性的相位偏移。
rippleAmplitude是相位的擴大因子。
rippleSpeed調節函數的周期,即波紋傳遞速度。
最後将偏移值乘以一個最大偏移範圍rippleOffset(一般為0.03),限定單個像素的偏移範圍,不然波紋會很不自然。
II.時間線動畫
設定顔色混合,在整個動畫過程中,圖1逐漸消失(1到0),圖2逐漸展現(0到1)。
設定畫布透明度,在起始時為1,逐漸變化到0.7,最後再逐漸回到1。
設定波紋的振幅,在起始時最大,過渡到動畫中間點到最小,最後逐漸變大到動畫結束。
設定波紋的速度,在起始時最大,過渡到動畫中間點到最小,最後逐漸變大到動畫結束。
設定波紋的像素最大偏移值,在起始時最大,過渡到動畫中間點到最小,最後逐漸變大到動畫結束。
protected void onPhaseInit() {
mMixIntensity = MIX_INTENSITY_START;
mCanvasAlpha = CANVAS_ALPHA_DEFAULT;
mRippleAmplitude = 0;
mRippleSpeed = 0;
mRippleOffset = 0;
}
protected void onPhaseEnter(float enterRatio) {
mMixIntensity = enterRatio * 0.5f;
mCanvasAlpha = 1f - enterRatio;
mRippleAmplitude = enterRatio * RIPPLE_AMPLITUDE_DEFAULT;
mRippleSpeed = enterRatio * RIPPLE_SPEED_DEFAULT;
mRippleOffset = enterRatio * RIPPLE_OFFSET_DEFAULT;
}
protected void onPhaseExit(float exitRatio) {
mMixIntensity = exitRatio * 0.5f + 0.5f;
mCanvasAlpha = exitRatio;
mRippleAmplitude = (1f - exitRatio) * RIPPLE_AMPLITUDE_DEFAULT;
mRippleSpeed = (1f - exitRatio) * RIPPLE_SPEED_DEFAULT;
mRippleOffset = (1f - exitRatio) * RIPPLE_OFFSET_DEFAULT;
}
protected void onPhaseFinish() {
mMixIntensity = MIX_INTENSITY_END;
mCanvasAlpha = CANVAS_ALPHA_DEFAULT;
mRippleAmplitude = 0;
mRippleSpeed = 0;
mRippleOffset = 0;
}
protected void onPhaseStep() {
if (mCanvasAlpha < CANVAS_ALPHA_MINIMUN) {
mCanvasAlpha = CANVAS_ALPHA_MINIMUN;
}
}
将本次動畫幀的參數更新到着色器:
long globalTimeMs = GLClock.get();
GLES20.glUniform1f(getHandle("rippleTime"), globalTimeMs / 1000f);
GLES20.glUniform1f(getHandle("rippleAmplitude"), mRippleAmplitude);
GLES20.glUniform1f(getHandle("rippleSpeed"), mRippleSpeed);
GLES20.glUniform1f(getHandle("rippleOffset"), mRippleOffset);
GLES20.glUniform2f(getHandle("rippleCenterShift"), mRippleCenterX, mRippleCenterY);
其中GLClock是一個與mediacodec編碼時間戳綁定的外部時鐘,用于同步合成時間和動畫時間戳位置。
III.最終效果
圖檔展示時長:3s
過渡動畫時長:1.5s
波紋中心為圖檔中心點
5.随機方格
I.噪聲函數
我們想實作的效果是前一個畫面上随機出現很多方塊,每個方塊中展示下一張圖的畫面,當圖檔上每一塊位置都形成方塊後就完成了畫面的轉換。
首先就需要解決随機函數的問題。雖然Java上有很多現成的随機函數,但是GLSL是個很底層的語言,基本上除了加減乘除其他的都需要自己想辦法。這個着色器裡用的rand函數是流傳已久幾乎找不到來源的一個實作,很有上古時期遊戲程式設計代碼的風格,有魔法數,代碼隻要一行,證明要寫兩頁。
網上一個比較靠譜且簡潔的說明是StackOverflow上的,這個随機函數實際是一個hash函數,對每一個相同的(x,y)輸入都會有相同的輸出。
II.着色器實作
uniform vec2 squares;
uniform float smoothness;
float rand(vec2 co) {
return fract(sin(dot(co.xy ,vec2(12.9898,78.233))) * 43758.5453);
};
void main(void) {
vec2 uv = vTextureCoord.xy;
float randomSquare = rand(floor(squares * uv));
float intensity = smoothstep(0.0, -smoothness, randomSquare - (progress * (1.0 + smoothness)));
vec4 fromColor = texture2D(preTexture, uv);
vec4 nextColor = texture2D(nextTexture, uv);
vec4 mixColor = mix(fromColor, nextColor, intensity);
gl_FragColor = vec4(mixColor.rgb, canvasAlpha);
}
首先将目前紋理坐标乘以方格大小,用随機函數轉換後擷取這個方格區域的随機漸變值。
然後用smoothstep做一個厄米特插值,将漸變的intensity平滑化。
最後用這個intensity值mix前後圖像序列。
III.效果圖
原文釋出時間為:2019-11-4
作者: 萊甯
本文來自雲栖社群合作夥伴“
阿裡技術”,了解相關資訊可以關注“
”。