阿里妹導讀:打開盒馬app,相信你跟阿里妹同樣,很難抵抗各類美味的誘惑。顏值即正義,盒馬的圖片視頻技術逼真地還原了食物細節,並在短短數秒內呈現出食物的最佳效果。今天,咱們請來阿里高級無線開發工程師萊寧,解密盒馬app裏那些「美味」視頻是如何生產的。
圖片合成視頻併產生相似PPT中每頁過渡特效的能力是目前不少短視頻軟件帶有的功能,好比抖音的影集。這個功能主要包括圖片合成視頻、轉場時間線定義和OpenGL特效等三個部分。編程
其中圖片轉視頻的流程直接決定了後面過渡特效的實現方案。這裏主要有兩種方案:canvas
方案1每一個流程都比較獨立,更方便實現,可是要重複處理兩次數據,一次合併一次加特效,耗時更長。架構
方案2的流程是相互穿插的,只須要處理一次數據,因此咱們採用這個方案。app
下面主要介紹下幾個重點流程,並以幾個簡單的轉場特效做爲例子,演示具體效果。dom
1.方案函數
圖片合成視頻有多種手段能夠實現。下面談一下比較常見的幾種技術實現。動畫
I.FFMPEG阿里雲
定義輸出編碼格式和幀率,而後指定須要處理的圖片列表便可合成視頻。編碼
ffmpeg -r 1/5 -i img%03d.png -c:v libx264 -vf fps=25 -pix_fmt yuv420p out.mp4
II.MediaCodecspa
在使用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。
其中過渡動畫的時間段又須要分爲:
動畫時間線通常只定義爲非淡入淡出外的其餘特效使用。爲了過渡的視覺連續性,先後序圖片的淡入和淡出是貫穿整個動畫時間的。考慮到序列的銜接性,退場完畢後會馬上入場,所以enterEndT=exitStartT。
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.像素化轉場
I.着色器實現
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)的兩次座標轉換,就把一個區間範圍內的紋理都映射爲了同一個顏色。
II.效果圖
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.效果圖
阿里雲雙11領億元補貼,拼手氣抽iPhone 11 Pro、衛衣等好禮,點此參與:http://t.cn/Ai1hLLJT
本文做者: 萊寧
本文來自雲棲社區合做夥伴「阿里技術」,如需轉載請聯繫原做者。